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/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 { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
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 -> lockPath | null
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
- workChildren.set(ch, lockPath ?? null);
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
- const runAgentPlan = async (job) => {
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(() => planning.delete(id));
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
- const runAgentTurn = async (job) => {
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
- workChildren.set(ch, null);
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(() => agentTurns.delete(id));
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, null);
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.82.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": {