flowviant 0.82.0 → 0.85.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/README.md +13 -0
- package/bin/lib/admission.mjs +116 -0
- package/bin/lib/config.mjs +44 -4
- package/bin/lib/env.mjs +24 -3
- package/bin/lib/fleet.mjs +393 -9
- package/bin/lib/instance.mjs +7 -0
- package/bin/lib/resources.mjs +226 -1
- package/bin/lib/runtimes.mjs +25 -0
- package/bin/lib/trace.mjs +194 -0
- package/bin/lib/work.mjs +457 -9
- package/package.json +1 -1
package/bin/lib/work.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
USER_AGENT,
|
|
37
37
|
REFRESH_BEFORE_SECONDS,
|
|
38
38
|
DAEMON_INSTANCE,
|
|
39
|
+
MACHINE_HOST,
|
|
39
40
|
MODEL,
|
|
40
41
|
} from './config.mjs';
|
|
41
42
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
@@ -63,15 +64,24 @@ import {
|
|
|
63
64
|
AGENT_TASK_KICKOFF,
|
|
64
65
|
AGENT_HUMAN_KICKOFF,
|
|
65
66
|
} from './prompts.mjs';
|
|
66
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
materializeInto,
|
|
69
|
+
hasMaterialized,
|
|
70
|
+
excludeInWorktree,
|
|
71
|
+
myPubB64,
|
|
72
|
+
scrub as envScrub,
|
|
73
|
+
} from './env.mjs';
|
|
67
74
|
import {
|
|
68
75
|
detectRuntimes,
|
|
69
76
|
canRun,
|
|
70
77
|
pickRuntimeFor,
|
|
71
78
|
recordSkills,
|
|
72
79
|
toolEventOf,
|
|
80
|
+
CLAUDE_TOOL_PROSE_KINDS,
|
|
73
81
|
RUNTIMES,
|
|
74
82
|
} from './runtimes.mjs';
|
|
83
|
+
import { createAdmission } from './admission.mjs';
|
|
84
|
+
import { makeTraceRelay } from './trace.mjs';
|
|
75
85
|
|
|
76
86
|
/** The place id meaning "the checkout", not a worktree. Must match the
|
|
77
87
|
* server's REPO_PLACE — it is a wire value, not a local convention. */
|
|
@@ -162,6 +172,15 @@ export function createWorkManager({
|
|
|
162
172
|
getLeaseTtl,
|
|
163
173
|
/** "The repo picture changed — look again." See the caller in fleet.mjs. */
|
|
164
174
|
onRepoChanged = () => {},
|
|
175
|
+
/**
|
|
176
|
+
* CLI turns this manager did not spawn — today exactly one, the wiki
|
|
177
|
+
* cartographer, which lives in fleet.mjs's own closure. A callback for the
|
|
178
|
+
* same reason `onRepoChanged` is one: work.mjs is imported BY fleet.mjs and
|
|
179
|
+
* cannot import back. It exists because the concurrency bound is a bound on
|
|
180
|
+
* the MACHINE: a count that can see three lanes out of four is a ceiling with
|
|
181
|
+
* a hole in it.
|
|
182
|
+
*/
|
|
183
|
+
extraLiveTurns = () => 0,
|
|
165
184
|
}) {
|
|
166
185
|
/**
|
|
167
186
|
* WHERE SHIP LANDS, read fresh every time rather than captured at startup.
|
|
@@ -193,6 +212,7 @@ export function createWorkManager({
|
|
|
193
212
|
const AGENT_PLAN_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-activity');
|
|
194
213
|
const AGENT_TURN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-turn-done');
|
|
195
214
|
const AGENT_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-activity');
|
|
215
|
+
const AGENT_TRACE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-trace');
|
|
196
216
|
const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
|
|
197
217
|
const AGENT_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-check-done');
|
|
198
218
|
const AGENT_MERGE_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-claim');
|
|
@@ -694,9 +714,31 @@ export function createWorkManager({
|
|
|
694
714
|
// `wrangler dev` alone opens nine, so the old cap of eight was already
|
|
695
715
|
// dropping a row on an ordinary stack with nothing on the wire to say so.
|
|
696
716
|
const lis = measureListeners(wt);
|
|
717
|
+
/**
|
|
718
|
+
* WHICH BOX MEASURED THIS — on an agent's report only.
|
|
719
|
+
*
|
|
720
|
+
* The server stores it on the agent row so a LATER turn can be checked
|
|
721
|
+
* against the box that actually holds the work: an agent's branch and its
|
|
722
|
+
* conversation exist on one machine's disk until somebody approves it, and
|
|
723
|
+
* two boxes on one credential can both be offered its turns. The daemon
|
|
724
|
+
* reports what it is; the server does the comparing.
|
|
725
|
+
*
|
|
726
|
+
* A daemon→server report on an endpoint that already exists, so no floor —
|
|
727
|
+
* an older daemon sends no key and the agent is left UNATTRIBUTED, which is
|
|
728
|
+
* a third state the server reads as "nobody said" rather than as "not this
|
|
729
|
+
* box". `envpub` is the identity for the same reason the poll uses it: it is
|
|
730
|
+
* durable per box, and the hostname beside it is only a label for a person
|
|
731
|
+
* to read. Absent when the keypair is unreadable — that machine is exempt
|
|
732
|
+
* from arbitration entirely, which is the fail-open direction.
|
|
733
|
+
*
|
|
734
|
+
* TABS GET NOTHING. A tab's place is shared by design and its work is a
|
|
735
|
+
* human's own directory; attributing one would be a fact with no reader.
|
|
736
|
+
*/
|
|
737
|
+
const pub = sessionId.startsWith('a-') && sessionId.length > 2 ? myPubB64() : null;
|
|
697
738
|
return {
|
|
698
739
|
sessionId,
|
|
699
740
|
...d,
|
|
741
|
+
...(pub ? { box: { id: pub, name: MACHINE_HOST } } : {}),
|
|
700
742
|
listening: lis.rows,
|
|
701
743
|
listeningTotal: lis.total,
|
|
702
744
|
listeningSupported: listenersSupported(),
|
|
@@ -2311,8 +2353,18 @@ export function createWorkManager({
|
|
|
2311
2353
|
* restarted daemon a green light to spawn a second CLI into the same held
|
|
2312
2354
|
* context. turnLockedByLivePid already covers both outcomes: it waits while
|
|
2313
2355
|
* the pid lives and clears the lock once it is dead.
|
|
2356
|
+
*
|
|
2357
|
+
* THE VALUE IS THE ID THE CHILD SERVES, and it used to be the pid-lock path —
|
|
2358
|
+
* which nothing in this file has ever read. A write-only value is not free:
|
|
2359
|
+
* the machine snapshot's per-task RSS (`/fleet/machine`) is the one readout
|
|
2360
|
+
* that answers "which task is holding nine gigabytes", and it was being built
|
|
2361
|
+
* from the dispatch-era `workers` map, which nothing has `.set()` since that
|
|
2362
|
+
* lane was deleted — so the column its own server handler calls the
|
|
2363
|
+
* load-bearing half of the report had never been populated once. This map is
|
|
2364
|
+
* the only place that knows both the pid and whose work it is, so it carries
|
|
2365
|
+
* both. `null` where there is no id to name (a Deploy press is not a task).
|
|
2314
2366
|
*/
|
|
2315
|
-
const workChildren = new Map(); // child process ->
|
|
2367
|
+
const workChildren = new Map(); // child process -> sessionId | agentId | null
|
|
2316
2368
|
/**
|
|
2317
2369
|
* Children whose whole PROCESS GROUP must go, not just the child.
|
|
2318
2370
|
*
|
|
@@ -2350,6 +2402,42 @@ export function createWorkManager({
|
|
|
2350
2402
|
groupKillChildren.clear();
|
|
2351
2403
|
};
|
|
2352
2404
|
|
|
2405
|
+
/**
|
|
2406
|
+
* HOW MANY CLIs THIS MACHINE IS RUNNING RIGHT NOW — the number
|
|
2407
|
+
* `MAX_CONCURRENT` is a ceiling on, and the thing that had no counter.
|
|
2408
|
+
*
|
|
2409
|
+
* Every lane, because the bound is on the BOX and not on a lane: session
|
|
2410
|
+
* turns, an agent's turn, a Deploy press's planner, a project check — all of
|
|
2411
|
+
* them land in `workChildren` — plus whatever the caller reports on top of it
|
|
2412
|
+
* (the wiki cartographer, which fleet.mjs owns).
|
|
2413
|
+
*
|
|
2414
|
+
* THE PROJECT CHECK COUNTS, deliberately. It is not a model turn, but it is a
|
|
2415
|
+
* full test or build run in a worktree, which is exactly the kind of process
|
|
2416
|
+
* this ceiling exists to stop stacking. Nothing gates a check, so counting it
|
|
2417
|
+
* cannot deadlock: it only ever delays the NEXT spawn.
|
|
2418
|
+
*/
|
|
2419
|
+
const liveTurnCount = () => {
|
|
2420
|
+
const extra = Number(extraLiveTurns() ?? 0);
|
|
2421
|
+
return workChildren.size + (Number.isFinite(extra) && extra > 0 ? extra : 0);
|
|
2422
|
+
};
|
|
2423
|
+
|
|
2424
|
+
/** The live turn children with the id each one serves — what the machine
|
|
2425
|
+
* snapshot charges its per-task RSS to. Children with no id (the planner)
|
|
2426
|
+
* are still counted above; they just have nothing to be charged TO. */
|
|
2427
|
+
const liveTurns = () => {
|
|
2428
|
+
const out = [];
|
|
2429
|
+
for (const [ch, id] of workChildren) if (ch?.pid) out.push({ id: id ?? null, pid: ch.pid });
|
|
2430
|
+
return out;
|
|
2431
|
+
};
|
|
2432
|
+
|
|
2433
|
+
/**
|
|
2434
|
+
* WHETHER TO START ONE MORE. See admission.mjs for the whole argument: a
|
|
2435
|
+
* runaway bound on a machine, read at the spawn, surfaced only as the
|
|
2436
|
+
* machine's own measured sentence at the thing that is waiting, and never a
|
|
2437
|
+
* reason to settle a job — a deferred job is re-offered next poll.
|
|
2438
|
+
*/
|
|
2439
|
+
const admit = createAdmission({ liveTurnCount });
|
|
2440
|
+
|
|
2353
2441
|
/**
|
|
2354
2442
|
* Retire the worktrees of sessions the server says are CLOSED.
|
|
2355
2443
|
*
|
|
@@ -2478,6 +2566,47 @@ export function createWorkManager({
|
|
|
2478
2566
|
}
|
|
2479
2567
|
};
|
|
2480
2568
|
|
|
2569
|
+
/**
|
|
2570
|
+
* TELL THE TAB IT IS WAITING ON THE BOX, not on its Claude.
|
|
2571
|
+
*
|
|
2572
|
+
* A deferred turn is invisible from a browser: the composer says "working…"
|
|
2573
|
+
* and the machine simply does not spawn, which looks exactly like a slow
|
|
2574
|
+
* model. So the deferral rides the narration channel the turn would have used
|
|
2575
|
+
* anyway — the turn is still pending, so the server accepts the line — and
|
|
2576
|
+
* says the measured reason and what happens next. The machine's own voice,
|
|
2577
|
+
* for a moment only this side can see; the same shape the planner's "waiting
|
|
2578
|
+
* for the checkout" already keeps.
|
|
2579
|
+
*
|
|
2580
|
+
* ONCE PER SESSION PER WINDOW, because the roster re-offers the same turn on
|
|
2581
|
+
* every poll and restating an unchanged sentence every ten seconds is a POST
|
|
2582
|
+
* loop, not a readout. The clock is cleared the moment a turn for that session
|
|
2583
|
+
* actually starts, so the next stall speaks immediately rather than inheriting
|
|
2584
|
+
* a window from an unrelated one.
|
|
2585
|
+
*/
|
|
2586
|
+
const DEFER_SAY_MS = 30_000;
|
|
2587
|
+
const lastDeferSaid = new Map(); // sessionId -> ms
|
|
2588
|
+
const sayTurnDeferred = (sessionId, turnId, reason) => {
|
|
2589
|
+
const now = Date.now();
|
|
2590
|
+
if (now - (lastDeferSaid.get(sessionId) ?? 0) < DEFER_SAY_MS) return;
|
|
2591
|
+
lastDeferSaid.set(sessionId, now);
|
|
2592
|
+
void fetch(ACTIVITY_URL, {
|
|
2593
|
+
method: 'POST',
|
|
2594
|
+
headers: {
|
|
2595
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
2596
|
+
'User-Agent': USER_AGENT,
|
|
2597
|
+
'Content-Type': 'application/json',
|
|
2598
|
+
},
|
|
2599
|
+
signal: AbortSignal.timeout(10_000),
|
|
2600
|
+
body: JSON.stringify({
|
|
2601
|
+
sessionId,
|
|
2602
|
+
turnId,
|
|
2603
|
+
lines: [`Deferred — ${reason}. The machine retries on its next poll.`],
|
|
2604
|
+
}),
|
|
2605
|
+
}).catch(() => {
|
|
2606
|
+
/* a readout — a dropped line is not an incident */
|
|
2607
|
+
});
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2481
2610
|
const processWorkTurns = (jobs) => {
|
|
2482
2611
|
for (const job of jobs ?? []) {
|
|
2483
2612
|
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
@@ -2485,6 +2614,30 @@ export function createWorkManager({
|
|
|
2485
2614
|
// The turn already RAN and its answer sits in the delivery queue — never
|
|
2486
2615
|
// run it again while the report is merely undelivered.
|
|
2487
2616
|
if (pendingWorkReports.has(job.id)) continue;
|
|
2617
|
+
/**
|
|
2618
|
+
* THE BOX IS ABOUT TO FALL OVER, OR THIS MACHINE IS ALREADY AT ITS
|
|
2619
|
+
* CEILING. Defer: return without settling and without consuming an
|
|
2620
|
+
* attempt — the shape the live-CLI lock below uses, for the same reason.
|
|
2621
|
+
* The job stays pending and the server re-offers it next poll; settling
|
|
2622
|
+
* it would tell the human their message failed when nothing ran.
|
|
2623
|
+
*
|
|
2624
|
+
* `interactive`, not `churn`: somebody is watching a composer they just
|
|
2625
|
+
* pressed enter in, so this holds out until the box is genuinely about to
|
|
2626
|
+
* die rather than yielding early the way the unattended lanes do.
|
|
2627
|
+
*
|
|
2628
|
+
* AND THE SLOT IS RESERVED BEFORE THE NEXT ITERATION ASKS. This loop is
|
|
2629
|
+
* synchronous and every spawn under it is not — `inPlace` resolves its
|
|
2630
|
+
* callback in a later microtask — so `liveTurnCount()` could not move
|
|
2631
|
+
* between jobs and a roster offering eight turns admitted all eight
|
|
2632
|
+
* against a ceiling of one. See admission.mjs.
|
|
2633
|
+
*/
|
|
2634
|
+
const hold = admit('interactive');
|
|
2635
|
+
if (hold) {
|
|
2636
|
+
sayTurnDeferred(job.sessionId, job.id, hold.reason);
|
|
2637
|
+
continue;
|
|
2638
|
+
}
|
|
2639
|
+
const releaseSlot = admit.reserve();
|
|
2640
|
+
lastDeferSaid.delete(job.sessionId);
|
|
2488
2641
|
workAnswering.add(job.id);
|
|
2489
2642
|
const place = job.place || job.sessionId;
|
|
2490
2643
|
/**
|
|
@@ -2504,6 +2657,7 @@ export function createWorkManager({
|
|
|
2504
2657
|
answer:
|
|
2505
2658
|
'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
|
|
2506
2659
|
}).finally(() => workAnswering.delete(job.id));
|
|
2660
|
+
releaseSlot();
|
|
2507
2661
|
continue;
|
|
2508
2662
|
}
|
|
2509
2663
|
// Remembered for every other beat — the sweep, ship, the preview
|
|
@@ -3066,7 +3220,13 @@ export function createWorkManager({
|
|
|
3066
3220
|
onSpawn: (ch) => {
|
|
3067
3221
|
if (!ch) return;
|
|
3068
3222
|
spawned.push(ch);
|
|
3069
|
-
|
|
3223
|
+
// Keyed to the SESSION it serves — that id is what the machine
|
|
3224
|
+
// snapshot charges this child's memory to.
|
|
3225
|
+
workChildren.set(ch, job.sessionId);
|
|
3226
|
+
// The process exists, so the reserved slot is now counted by
|
|
3227
|
+
// the registry itself. Idempotent — the `finally` below releases
|
|
3228
|
+
// it again for every path that never got here.
|
|
3229
|
+
releaseSlot();
|
|
3070
3230
|
// The CLI is spawned `detached`, so its pid IS its process
|
|
3071
3231
|
// group id — and every process it starts inherits that, through
|
|
3072
3232
|
// `nohup` and `setsid` alike. Remembered per SESSION rather
|
|
@@ -3240,6 +3400,10 @@ export function createWorkManager({
|
|
|
3240
3400
|
warn(`session turn failed: ${e?.message ?? e}`);
|
|
3241
3401
|
} finally {
|
|
3242
3402
|
workAnswering.delete(job.id);
|
|
3403
|
+
// Nothing spawned, or everything already has: releasing twice is the
|
|
3404
|
+
// normal case and costs nothing. A reservation that leaked would
|
|
3405
|
+
// shrink this machine's ceiling for the life of the process.
|
|
3406
|
+
releaseSlot();
|
|
3243
3407
|
// The turn just changed the directory — say what it looks like now,
|
|
3244
3408
|
// whether it succeeded or blew up (a failed turn can still have
|
|
3245
3409
|
// written half a file, and the tab should show that honestly). NOT
|
|
@@ -3788,7 +3952,9 @@ export function createWorkManager({
|
|
|
3788
3952
|
return [...out];
|
|
3789
3953
|
};
|
|
3790
3954
|
|
|
3791
|
-
|
|
3955
|
+
/** See `runAgentTurn` — the admission reservation, released the moment the
|
|
3956
|
+
* planner's CLI exists. */
|
|
3957
|
+
const runAgentPlan = async (job, releaseSlot = () => {}) => {
|
|
3792
3958
|
const id = String(job.id);
|
|
3793
3959
|
const tasks = Array.isArray(job.tasks) ? job.tasks : [];
|
|
3794
3960
|
// CLAIM BEFORE ANYTHING — including before the cheap refusal below.
|
|
@@ -3916,7 +4082,11 @@ export function createWorkManager({
|
|
|
3916
4082
|
},
|
|
3917
4083
|
onSpawn: (ch) => {
|
|
3918
4084
|
planChild = ch;
|
|
4085
|
+
// No id: a Deploy press is not a task, and the snapshot's per-task
|
|
4086
|
+
// rows must not invent one. It still COUNTS against the machine's
|
|
4087
|
+
// ceiling — see liveTurnCount.
|
|
3919
4088
|
workChildren.set(ch, null);
|
|
4089
|
+
releaseSlot();
|
|
3920
4090
|
say(`${RUNTIMES[rt]?.label ?? rt} started on this machine`);
|
|
3921
4091
|
/**
|
|
3922
4092
|
* ARMED AT THE SPAWN, not at the claim: time spent waiting for the
|
|
@@ -3986,8 +4156,27 @@ export function createWorkManager({
|
|
|
3986
4156
|
for (const job of jobs.slice(0, 1)) {
|
|
3987
4157
|
const id = String(job?.id || '');
|
|
3988
4158
|
if (!id || planning.has(id)) continue;
|
|
4159
|
+
/**
|
|
4160
|
+
* BEFORE THE CLAIM, and that ordering is the whole point: `runAgentPlan`
|
|
4161
|
+
* claims the press as its first act, and a claimed press must be settled
|
|
4162
|
+
* or it sits open holding its cards out of Deploy. NOT claiming is how
|
|
4163
|
+
* this lane declines — the press stays queued, the server offers it
|
|
4164
|
+
* again next poll, and nobody is told their Deploy failed.
|
|
4165
|
+
*/
|
|
4166
|
+
const hold = admit('churn');
|
|
4167
|
+
if (hold) {
|
|
4168
|
+
note(`${c.cyan('plan')} ${c.dim(`— holding off: ${hold.reason}`)}`);
|
|
4169
|
+
continue;
|
|
4170
|
+
}
|
|
4171
|
+
// One press a tick, so this lane cannot burst on its own — but the slot
|
|
4172
|
+
// it is about to take has to be visible to the agent-turn lane that runs
|
|
4173
|
+
// moments later in the same reconcile. See admission.mjs.
|
|
4174
|
+
const releaseSlot = admit.reserve();
|
|
3989
4175
|
planning.add(id);
|
|
3990
|
-
void runAgentPlan(job).finally(() =>
|
|
4176
|
+
void runAgentPlan(job, releaseSlot).finally(() => {
|
|
4177
|
+
releaseSlot();
|
|
4178
|
+
planning.delete(id);
|
|
4179
|
+
});
|
|
3991
4180
|
}
|
|
3992
4181
|
};
|
|
3993
4182
|
|
|
@@ -4081,6 +4270,42 @@ export function createWorkManager({
|
|
|
4081
4270
|
}
|
|
4082
4271
|
};
|
|
4083
4272
|
|
|
4273
|
+
/**
|
|
4274
|
+
* ONE BATCH OF A TURN'S TRACE. See trace.mjs for the whole contract.
|
|
4275
|
+
*
|
|
4276
|
+
* Resolves TRUE for a permanent refusal as well as a success, and that is
|
|
4277
|
+
* deliberate: a server with no such route 404s every batch, and a relay that
|
|
4278
|
+
* held them would fill its buffer, shed the turn's real steps and retry the
|
|
4279
|
+
* same rejected body for the life of the turn. There is no version floor here
|
|
4280
|
+
* — this is a daemon→server report, so an older server simply never learns
|
|
4281
|
+
* the trace and the board renders what it always did.
|
|
4282
|
+
*/
|
|
4283
|
+
const postAgentTrace = async (body) => {
|
|
4284
|
+
try {
|
|
4285
|
+
const res = await fetch(AGENT_TRACE_URL, {
|
|
4286
|
+
method: 'POST',
|
|
4287
|
+
headers: {
|
|
4288
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
4289
|
+
'User-Agent': USER_AGENT,
|
|
4290
|
+
'Content-Type': 'application/json',
|
|
4291
|
+
},
|
|
4292
|
+
signal: AbortSignal.timeout(15_000),
|
|
4293
|
+
body: JSON.stringify(body),
|
|
4294
|
+
});
|
|
4295
|
+
return (
|
|
4296
|
+
res.ok ||
|
|
4297
|
+
(res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
4298
|
+
);
|
|
4299
|
+
} catch {
|
|
4300
|
+
return false; // a blip — the same entries go again at the same seq
|
|
4301
|
+
}
|
|
4302
|
+
};
|
|
4303
|
+
|
|
4304
|
+
/** How long the final flush may hold the settle. Bounded because the settle
|
|
4305
|
+
* is the turn's contract and the trace is a readout: a wedged uplink costs
|
|
4306
|
+
* the tail of a trace, never the answer behind it. */
|
|
4307
|
+
const TRACE_FINAL_FLUSH_MS = 8_000;
|
|
4308
|
+
|
|
4084
4309
|
const postAgentActivity = async (agentId, text) => {
|
|
4085
4310
|
try {
|
|
4086
4311
|
await fetch(AGENT_ACTIVITY_URL, {
|
|
@@ -4163,7 +4388,10 @@ export function createWorkManager({
|
|
|
4163
4388
|
: [];
|
|
4164
4389
|
};
|
|
4165
4390
|
|
|
4166
|
-
|
|
4391
|
+
/** `releaseSlot` hands back the admission reservation the caller took on this
|
|
4392
|
+
* turn's behalf, at the moment the CLI actually exists. Idempotent and
|
|
4393
|
+
* optional — a caller with no reservation passes nothing. */
|
|
4394
|
+
const runAgentTurn = async (job, releaseSlot = () => {}) => {
|
|
4167
4395
|
const turnId = String(job.id);
|
|
4168
4396
|
const agentId = String(job.agentId || '');
|
|
4169
4397
|
const place = String(job.placeId || '');
|
|
@@ -4201,6 +4429,76 @@ export function createWorkManager({
|
|
|
4201
4429
|
// places: those are agents' by construction, and anything else here would
|
|
4202
4430
|
// be a tab's directory, where a turn is a reader by the product's own law.
|
|
4203
4431
|
await inPlace(place, place.startsWith('a-'), async () => {
|
|
4432
|
+
/**
|
|
4433
|
+
* WORK THAT HAS BEGUN LIVES ON EXACTLY ONE BOX, AND THIS MAY NOT BE IT.
|
|
4434
|
+
*
|
|
4435
|
+
* A project has ONE machine credential and every device is handed the same
|
|
4436
|
+
* raw token, so two boxes can both be polling for the same agents. Nothing
|
|
4437
|
+
* pushes an agent's branch before approve, so a turn that has already run
|
|
4438
|
+
* somewhere has its worktree, its branch and its CONVERSATION on that box's
|
|
4439
|
+
* disk and nowhere else. `placeWtFor` cannot tell the difference: it finds
|
|
4440
|
+
* no directory, cuts a fresh `session/a-<id>` off base, and the CLI starts
|
|
4441
|
+
* with no memory of the card — a confident, context-free redo of work
|
|
4442
|
+
* somebody is in the middle of, on the operator's shared account, landing
|
|
4443
|
+
* on a rival branch of the same name.
|
|
4444
|
+
*
|
|
4445
|
+
* So: if the server says this agent has BEGUN and this box holds neither
|
|
4446
|
+
* its directory nor its branch, refuse before anything is cut. `nothing` is
|
|
4447
|
+
* the honest outcome — this machine did not run the turn — and the sentence
|
|
4448
|
+
* says what was measured (two absences here, and the box name only when the
|
|
4449
|
+
* server recorded one; inferring where the work is would be invention).
|
|
4450
|
+
*
|
|
4451
|
+
* THE BRANCH ALONE IS ENOUGH TO CONTINUE. `placeWtFor`'s attach fallback
|
|
4452
|
+
* re-attaches a worktree to a surviving branch, so a directory somebody
|
|
4453
|
+
* cleaned up on THIS box is same-box recovery of real committed work and
|
|
4454
|
+
* behaves exactly as it did before this guard existed.
|
|
4455
|
+
*
|
|
4456
|
+
* The remedy is the stop path and nothing else. "Reconnect the other
|
|
4457
|
+
* machine" is not reachable from here once holdership has moved, and a
|
|
4458
|
+
* remedy somebody cannot carry out is worse than none.
|
|
4459
|
+
*/
|
|
4460
|
+
if (job.begun) {
|
|
4461
|
+
const wtDir = join(baseDir, 'sessions', place);
|
|
4462
|
+
let hasBranch = false;
|
|
4463
|
+
/**
|
|
4464
|
+
* THREE STATES, AND THE MIDDLE ONE IS WHY THIS IS NOT A BARE CATCH.
|
|
4465
|
+
*
|
|
4466
|
+
* `rev-parse --verify --quiet` exits 1 and prints nothing for a ref that
|
|
4467
|
+
* is not there — that exit code IS the measurement, and it is the one
|
|
4468
|
+
* this guard acts on. Any OTHER failure (128 for "not a repository",
|
|
4469
|
+
* ENOENT for no git at all, a momentary index lock) measured nothing;
|
|
4470
|
+
* collapsing it onto "the branch is absent" would make the daemon assert
|
|
4471
|
+
* "this machine does not hold this agent's branch" off a repo it could
|
|
4472
|
+
* not read — the guard inventing the very fact it exists to relay.
|
|
4473
|
+
*
|
|
4474
|
+
* So an unmeasured branch stands the guard DOWN. That re-enters the path
|
|
4475
|
+
* this guard is a belt for, which is the fail-open direction it wants;
|
|
4476
|
+
* `placeWtFor` is about to fail on the same unreadable repo and say so
|
|
4477
|
+
* in its own words, which is the honest sentence.
|
|
4478
|
+
*/
|
|
4479
|
+
let branchMeasured = true;
|
|
4480
|
+
try {
|
|
4481
|
+
hasBranch = Boolean(
|
|
4482
|
+
git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot)
|
|
4483
|
+
);
|
|
4484
|
+
} catch (e) {
|
|
4485
|
+
if (e?.status === 1) hasBranch = false;
|
|
4486
|
+
else branchMeasured = false;
|
|
4487
|
+
}
|
|
4488
|
+
if (branchMeasured && !existsSync(wtDir) && !hasBranch) {
|
|
4489
|
+
const on = typeof job.begunOn === 'string' && job.begunOn.trim()
|
|
4490
|
+
? job.begunOn.trim().slice(0, 64)
|
|
4491
|
+
: null;
|
|
4492
|
+
await postAgentTurn({
|
|
4493
|
+
turnId,
|
|
4494
|
+
outcome: 'nothing',
|
|
4495
|
+
answer:
|
|
4496
|
+
`This machine does not hold this agent's worktree or branch${on ? ` — its work is on ${on}` : ''}. ` +
|
|
4497
|
+
'Stop the agent to re-plan it here.',
|
|
4498
|
+
});
|
|
4499
|
+
return;
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4204
4502
|
const dir = placeWtFor(place);
|
|
4205
4503
|
if (!dir) {
|
|
4206
4504
|
// No worktree and none could be cut. `nothing` rather than an invented
|
|
@@ -4263,6 +4561,45 @@ export function createWorkManager({
|
|
|
4263
4561
|
*/
|
|
4264
4562
|
const resume = rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
|
|
4265
4563
|
|
|
4564
|
+
/**
|
|
4565
|
+
* THE WHOLE STREAM, not just its latest line — see trace.mjs.
|
|
4566
|
+
*
|
|
4567
|
+
* The pulse below is untouched and still sent: it carries staleness (how
|
|
4568
|
+
* long the machine has been quiet), which an append-only list of steps
|
|
4569
|
+
* cannot say, because a list that stopped growing looks exactly like a
|
|
4570
|
+
* list that is finished.
|
|
4571
|
+
*/
|
|
4572
|
+
const trace = makeTraceRelay({
|
|
4573
|
+
agentId,
|
|
4574
|
+
turnId,
|
|
4575
|
+
post: postAgentTrace,
|
|
4576
|
+
scrub: envScrub,
|
|
4577
|
+
});
|
|
4578
|
+
/**
|
|
4579
|
+
* Prose the structured event will carry anyway, dropped so a read does
|
|
4580
|
+
* not render twice — but ONLY on a runtime whose stream reaches
|
|
4581
|
+
* `onToolEvent` at all. Codex and agy have their own parsers and never
|
|
4582
|
+
* call it, so dropping their tool prose would blank their agents' traces.
|
|
4583
|
+
* `parse: null` is exactly the claude.mjs stream path. See
|
|
4584
|
+
* CLAUDE_TOOL_PROSE_KINDS.
|
|
4585
|
+
*/
|
|
4586
|
+
const doubledKinds = RUNTIMES[rt]?.parse ? null : CLAUDE_TOOL_PROSE_KINDS;
|
|
4587
|
+
|
|
4588
|
+
/**
|
|
4589
|
+
* WHICH BRAIN THIS CONTAINER WAS PINNED TO — the same `brainFor` the tab
|
|
4590
|
+
* lane runs, on the same two job keys, because an agent turn and a
|
|
4591
|
+
* session turn differ in who is watching and in nothing else that a model
|
|
4592
|
+
* name touches. Every guard lives in `brainFor`: a second copy here would
|
|
4593
|
+
* be a second answer to "is this a model we can spell", and the two would
|
|
4594
|
+
* drift the first time one of them learned a new effort.
|
|
4595
|
+
*
|
|
4596
|
+
* Absent stays genuinely absent — an agent nobody pinned produces the
|
|
4597
|
+
* byte-identical argv it produced yesterday, on the machine's own
|
|
4598
|
+
* default. That is also what an OLDER server yields, since it sends
|
|
4599
|
+
* neither key.
|
|
4600
|
+
*/
|
|
4601
|
+
const brain = brainFor(job);
|
|
4602
|
+
|
|
4266
4603
|
let out = '';
|
|
4267
4604
|
let child = null;
|
|
4268
4605
|
try {
|
|
@@ -4287,6 +4624,8 @@ export function createWorkManager({
|
|
|
4287
4624
|
cwd: wt,
|
|
4288
4625
|
runtime: rt,
|
|
4289
4626
|
resume,
|
|
4627
|
+
// Present only when the container named one — see brainFor.
|
|
4628
|
+
...brain,
|
|
4290
4629
|
streamJson: true,
|
|
4291
4630
|
answerFromResult: true,
|
|
4292
4631
|
label: c.cyan('[agent]'),
|
|
@@ -4296,14 +4635,31 @@ export function createWorkManager({
|
|
|
4296
4635
|
onActivity: (a) => {
|
|
4297
4636
|
const line = a?.label;
|
|
4298
4637
|
if (!line) return;
|
|
4638
|
+
// THE TRACE TAKES EVERY LINE; the pulse takes one every two
|
|
4639
|
+
// seconds. Two channels, one stream, and the drop-sampler stays a
|
|
4640
|
+
// drop-sampler — buffering the pulse would make a stale line look
|
|
4641
|
+
// fresh, which is the one thing it exists to answer.
|
|
4642
|
+
if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, line);
|
|
4299
4643
|
const now = Date.now();
|
|
4300
4644
|
if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
|
|
4301
4645
|
lastAgentBeat.set(agentId, now);
|
|
4302
4646
|
void postAgentActivity(agentId, envScrub(String(line)).slice(0, 400));
|
|
4303
4647
|
},
|
|
4648
|
+
// The structured twin of the line above — the same `tool_use` the
|
|
4649
|
+
// Workbench's tool cards are built from, scrubbed at collection by
|
|
4650
|
+
// the builder itself (bounded window BEFORE its caps; see
|
|
4651
|
+
// toolEventOf). A tool it does not know pushes nothing.
|
|
4652
|
+
onToolEvent: (name, input) => {
|
|
4653
|
+
trace.tool(toolEventOf(name, input, wt, envScrub));
|
|
4654
|
+
},
|
|
4304
4655
|
onSpawn: (ch) => {
|
|
4305
4656
|
child = ch;
|
|
4306
|
-
|
|
4657
|
+
// The AGENT it serves — what the machine snapshot charges this
|
|
4658
|
+
// child's memory to.
|
|
4659
|
+
workChildren.set(ch, agentId);
|
|
4660
|
+
// …and the registry now counts what the reservation was standing
|
|
4661
|
+
// in for.
|
|
4662
|
+
releaseSlot();
|
|
4307
4663
|
noteSessionGroup(agentId, ch.pid);
|
|
4308
4664
|
// Keyed by PLACE, because the retire sweep iterates directory names
|
|
4309
4665
|
// and a place id IS one. It is what lets a hard stop actually reach
|
|
@@ -4312,6 +4668,18 @@ export function createWorkManager({
|
|
|
4312
4668
|
},
|
|
4313
4669
|
});
|
|
4314
4670
|
} finally {
|
|
4671
|
+
/**
|
|
4672
|
+
* THE TAIL, BEFORE THE SETTLE — so the last thing the agent did is on
|
|
4673
|
+
* the record by the time the board is told the turn is over.
|
|
4674
|
+
*
|
|
4675
|
+
* The server deliberately does NOT require a pending turn to accept a
|
|
4676
|
+
* trace batch (a late tail is still that turn's record), so a race here
|
|
4677
|
+
* is survivable rather than lossy; flushing first simply means it
|
|
4678
|
+
* almost never happens. Bounded, and the settle is what matters: an
|
|
4679
|
+
* uplink that will not answer costs the tail and nothing else.
|
|
4680
|
+
*/
|
|
4681
|
+
trace.stop();
|
|
4682
|
+
await trace.flush(TRACE_FINAL_FLUSH_MS);
|
|
4315
4683
|
if (child) workChildren.delete(child);
|
|
4316
4684
|
if (agentChildren.get(place) === child) agentChildren.delete(place);
|
|
4317
4685
|
if (ranMarker) {
|
|
@@ -4423,6 +4791,8 @@ export function createWorkManager({
|
|
|
4423
4791
|
else if (now - held.at > AGENT_REPORT_GRACE_MS) agentReported.delete(id);
|
|
4424
4792
|
}
|
|
4425
4793
|
}
|
|
4794
|
+
/** One deferral line per tick, however many turns were offered. */
|
|
4795
|
+
let saidPressure = false;
|
|
4426
4796
|
for (const job of list.slice(0, 4)) {
|
|
4427
4797
|
const id = String(job?.id || '');
|
|
4428
4798
|
if (!id || agentTurns.has(id)) continue;
|
|
@@ -4450,9 +4820,76 @@ export function createWorkManager({
|
|
|
4450
4820
|
continue;
|
|
4451
4821
|
}
|
|
4452
4822
|
if (!job.agentId || !job.placeId) continue;
|
|
4823
|
+
/**
|
|
4824
|
+
* NOT NOW — and NOT SETTLED. An agent turn is the heaviest thing this
|
|
4825
|
+
* machine starts (a CLI with build permissions in its own worktree), and
|
|
4826
|
+
* four of them a tick with nothing looking at memory is how the daemon
|
|
4827
|
+
* froze somebody's computer.
|
|
4828
|
+
*
|
|
4829
|
+
* Deferring costs the job nothing: it is unleased, the server re-offers
|
|
4830
|
+
* it on the next poll, and no attempt is consumed. Settling it would be
|
|
4831
|
+
* the opposite — it would send the agent to Stuck over a turn this
|
|
4832
|
+
* machine never ran.
|
|
4833
|
+
*
|
|
4834
|
+
* Checked here rather than inside `runAgentTurn` so a HELD BODY above
|
|
4835
|
+
* still re-POSTs: that path spawns nothing, and holding a finished
|
|
4836
|
+
* turn's settle because the box is busy would park an agent for the
|
|
4837
|
+
* server's whole expiry. One LOG line per tick, not per job — a console
|
|
4838
|
+
* restating one unchanged fact four times is noise.
|
|
4839
|
+
*
|
|
4840
|
+
* The DECISION, though, is per job and has to be: spawns in this loop are
|
|
4841
|
+
* async, so `workChildren` cannot grow between iterations and four turns
|
|
4842
|
+
* would all be admitted against the same stale count. The reserved slot
|
|
4843
|
+
* is what the next iteration sees. See admission.mjs.
|
|
4844
|
+
*/
|
|
4845
|
+
const hold = admit('churn');
|
|
4846
|
+
if (hold) {
|
|
4847
|
+
if (!saidPressure) {
|
|
4848
|
+
saidPressure = true;
|
|
4849
|
+
note(`${c.cyan('agent')} ${c.dim(`— holding off: ${hold.reason}`)}`);
|
|
4850
|
+
}
|
|
4851
|
+
continue;
|
|
4852
|
+
}
|
|
4853
|
+
const releaseSlot = admit.reserve();
|
|
4453
4854
|
agentTurns.add(id);
|
|
4454
|
-
void runAgentTurn(job).finally(() =>
|
|
4855
|
+
void runAgentTurn(job, releaseSlot).finally(() => {
|
|
4856
|
+
// Belt for every path that never reached a spawn — the release is
|
|
4857
|
+
// idempotent, so the normal case releases twice.
|
|
4858
|
+
releaseSlot();
|
|
4859
|
+
agentTurns.delete(id);
|
|
4860
|
+
});
|
|
4861
|
+
}
|
|
4862
|
+
};
|
|
4863
|
+
|
|
4864
|
+
/**
|
|
4865
|
+
* SETTLE EVERYTHING IN FLIGHT, because this process is about to go away.
|
|
4866
|
+
*
|
|
4867
|
+
* The one caller is the displacement stand-down: the project's machine moved
|
|
4868
|
+
* to another box, so nothing here will be re-offered to us and nothing else
|
|
4869
|
+
* knows these turns were running. An abandoned turn sits pending until the
|
|
4870
|
+
* server's six-hour expiry while the board shows an agent working on a machine
|
|
4871
|
+
* that has gone — the wedge every settle path in this lane exists to avoid.
|
|
4872
|
+
*
|
|
4873
|
+
* A HELD BODY OUTRANKS THE SENTENCE, and that is not an optimisation. A turn
|
|
4874
|
+
* whose CLI already FINISHED has a real answer queued (delivered, a question,
|
|
4875
|
+
* its commits); posting `nothing` over it would be this daemon lying about
|
|
4876
|
+
* work it actually did, and the settle is conditional on the row still being
|
|
4877
|
+
* pending, so whichever POST lands first is the one the board believes. The
|
|
4878
|
+
* held bodies are retried here for the same reason the commanded stop flushes
|
|
4879
|
+
* the tab queues: they exist only in this process.
|
|
4880
|
+
*
|
|
4881
|
+
* Bounded by what is in flight, and awaited by the caller behind a clock —
|
|
4882
|
+
* a wedged uplink must not hold the stand-down open.
|
|
4883
|
+
*/
|
|
4884
|
+
const settleAgentTurns = async (sentence) => {
|
|
4885
|
+
const answer = String(sentence ?? '').slice(0, 1000);
|
|
4886
|
+
const posts = [];
|
|
4887
|
+
for (const turnId of agentTurns) {
|
|
4888
|
+
if (agentReported.has(turnId)) continue; // its own answer goes below
|
|
4889
|
+
posts.push(postAgentTurn({ turnId, outcome: 'nothing', answer }));
|
|
4455
4890
|
}
|
|
4891
|
+
for (const [, held] of agentReported) posts.push(postAgentTurn(held.body));
|
|
4892
|
+
await Promise.allSettled(posts);
|
|
4456
4893
|
};
|
|
4457
4894
|
|
|
4458
4895
|
// ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
|
|
@@ -4547,7 +4984,7 @@ export function createWorkManager({
|
|
|
4547
4984
|
* `groupKillChildren` for why this one is exempt from the
|
|
4548
4985
|
* never-signal-the-group rule.
|
|
4549
4986
|
*/
|
|
4550
|
-
workChildren.set(child,
|
|
4987
|
+
workChildren.set(child, agentId);
|
|
4551
4988
|
groupKillChildren.add(child);
|
|
4552
4989
|
} catch (e) {
|
|
4553
4990
|
// TEXT BEFORE FINISH: `finish` captures `text` by value into the
|
|
@@ -5089,8 +5526,19 @@ export function createWorkManager({
|
|
|
5089
5526
|
reportWorktrees,
|
|
5090
5527
|
shutdownWork,
|
|
5091
5528
|
workBusy,
|
|
5529
|
+
// The machine's own admission answer, and what it is counting. Handed to
|
|
5530
|
+
// the loop so the lanes fleet.mjs owns — the wiki cartographer — ask the
|
|
5531
|
+
// same question, and so the machine snapshot can charge memory to the work
|
|
5532
|
+
// holding it.
|
|
5533
|
+
admit,
|
|
5534
|
+
liveTurns,
|
|
5535
|
+
liveTurnCount,
|
|
5092
5536
|
processAgentPlanJobs,
|
|
5093
5537
|
processAgentTurnJobs,
|
|
5538
|
+
// Only the displacement stand-down calls this — see its comment. Exported
|
|
5539
|
+
// rather than hooked into `shutdownWork` because the signal handlers cannot
|
|
5540
|
+
// await, and a settle that is not awaited is a settle that did not happen.
|
|
5541
|
+
settleAgentTurns,
|
|
5094
5542
|
processAgentMergeJobs,
|
|
5095
5543
|
freshenManualPlaces,
|
|
5096
5544
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|