flowviant 0.81.0 → 0.84.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';
@@ -52,7 +53,9 @@ import { c, note, ok, warn } from './ui.mjs';
52
53
  import { mcpFor, runTurn } from './claude.mjs';
53
54
  import {
54
55
  SYSTEM_WORK,
56
+ SYSTEM_CAPTURE,
55
57
  WORK_TURN_KICKOFF,
58
+ CAPTURE_TURN_KICKOFF,
56
59
  SYSTEM_WORK_PLAIN,
57
60
  WORK_TURN_KICKOFF_PLAIN,
58
61
  SYSTEM_PLAN,
@@ -61,15 +64,24 @@ import {
61
64
  AGENT_TASK_KICKOFF,
62
65
  AGENT_HUMAN_KICKOFF,
63
66
  } from './prompts.mjs';
64
- 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';
65
74
  import {
66
75
  detectRuntimes,
67
76
  canRun,
68
77
  pickRuntimeFor,
69
78
  recordSkills,
70
79
  toolEventOf,
80
+ CLAUDE_TOOL_PROSE_KINDS,
71
81
  RUNTIMES,
72
82
  } from './runtimes.mjs';
83
+ import { createAdmission } from './admission.mjs';
84
+ import { makeTraceRelay } from './trace.mjs';
73
85
 
74
86
  /** The place id meaning "the checkout", not a worktree. Must match the
75
87
  * server's REPO_PLACE — it is a wire value, not a local convention. */
@@ -160,6 +172,15 @@ export function createWorkManager({
160
172
  getLeaseTtl,
161
173
  /** "The repo picture changed — look again." See the caller in fleet.mjs. */
162
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,
163
184
  }) {
164
185
  /**
165
186
  * WHERE SHIP LANDS, read fresh every time rather than captured at startup.
@@ -191,6 +212,7 @@ export function createWorkManager({
191
212
  const AGENT_PLAN_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-activity');
192
213
  const AGENT_TURN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-turn-done');
193
214
  const AGENT_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-activity');
215
+ const AGENT_TRACE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-trace');
194
216
  const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
195
217
  const AGENT_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-check-done');
196
218
  const AGENT_MERGE_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-claim');
@@ -692,9 +714,31 @@ export function createWorkManager({
692
714
  // `wrangler dev` alone opens nine, so the old cap of eight was already
693
715
  // dropping a row on an ordinary stack with nothing on the wire to say so.
694
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;
695
738
  return {
696
739
  sessionId,
697
740
  ...d,
741
+ ...(pub ? { box: { id: pub, name: MACHINE_HOST } } : {}),
698
742
  listening: lis.rows,
699
743
  listeningTotal: lis.total,
700
744
  listeningSupported: listenersSupported(),
@@ -2309,8 +2353,18 @@ export function createWorkManager({
2309
2353
  * restarted daemon a green light to spawn a second CLI into the same held
2310
2354
  * context. turnLockedByLivePid already covers both outcomes: it waits while
2311
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).
2312
2366
  */
2313
- const workChildren = new Map(); // child process -> lockPath | null
2367
+ const workChildren = new Map(); // child process -> sessionId | agentId | null
2314
2368
  /**
2315
2369
  * Children whose whole PROCESS GROUP must go, not just the child.
2316
2370
  *
@@ -2348,6 +2402,42 @@ export function createWorkManager({
2348
2402
  groupKillChildren.clear();
2349
2403
  };
2350
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
+
2351
2441
  /**
2352
2442
  * Retire the worktrees of sessions the server says are CLOSED.
2353
2443
  *
@@ -2476,6 +2566,47 @@ export function createWorkManager({
2476
2566
  }
2477
2567
  };
2478
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
+
2479
2610
  const processWorkTurns = (jobs) => {
2480
2611
  for (const job of jobs ?? []) {
2481
2612
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
@@ -2483,6 +2614,30 @@ export function createWorkManager({
2483
2614
  // The turn already RAN and its answer sits in the delivery queue — never
2484
2615
  // run it again while the report is merely undelivered.
2485
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);
2486
2641
  workAnswering.add(job.id);
2487
2642
  const place = job.place || job.sessionId;
2488
2643
  /**
@@ -2502,6 +2657,7 @@ export function createWorkManager({
2502
2657
  answer:
2503
2658
  'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
2504
2659
  }).finally(() => workAnswering.delete(job.id));
2660
+ releaseSlot();
2505
2661
  continue;
2506
2662
  }
2507
2663
  // Remembered for every other beat — the sweep, ship, the preview
@@ -2982,6 +3138,14 @@ export function createWorkManager({
2982
3138
  const message = [job.body, filesNote, adoptNote, carryNote]
2983
3139
  .filter(Boolean)
2984
3140
  .join('\n\n');
3141
+ // A CAPTURE chat (the board's New task conversation): its own
3142
+ // system prompt, its own kickoff, and the scratch planner's
3143
+ // READ-ONLY permission profile — the prompt says stage-never-file
3144
+ // and the profile is what makes "read-only" true rather than
3145
+ // asserted. Server-flagged per job; a server too old to flag it
3146
+ // simply runs an ordinary tab, which the web's version floor
3147
+ // prevents ever being offered.
3148
+ const captureTab = job.capture === true;
2985
3149
  const turnArgs = {
2986
3150
  // A plain tab has no tools to name and no session id to pass —
2987
3151
  // its kickoff asks for one complete report instead of a stream.
@@ -2991,18 +3155,26 @@ export function createWorkManager({
2991
3155
  message,
2992
3156
  askedByName: job.askedByName,
2993
3157
  })
2994
- : WORK_TURN_KICKOFF({
3158
+ : captureTab
3159
+ ? CAPTURE_TURN_KICKOFF({
3160
+ sessionId: job.sessionId,
3161
+ sessionName: job.sessionName,
3162
+ message,
3163
+ askedByName: job.askedByName,
3164
+ })
3165
+ : WORK_TURN_KICKOFF({
2995
3166
  sessionId: job.sessionId,
2996
3167
  sessionName: job.sessionName,
2997
3168
  message,
2998
3169
  askedByName: job.askedByName,
2999
3170
  }),
3171
+ planPerm: captureTab,
3000
3172
  // The adopt turn resumes the TERMINAL conversation by forking it
3001
3173
  // into this cwd (claude: --resume <id> --fork-session). After it
3002
3174
  // speaks once, the fork lives natively here and turn 2+ is the
3003
3175
  // ordinary --continue resume path, unchanged.
3004
3176
  ...(adopting ? { adoptResumeId: job.adopt.id } : {}),
3005
- system: plainTab ? SYSTEM_WORK_PLAIN : SYSTEM_WORK,
3177
+ system: plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
3006
3178
  // Present only when the tab named one — see brainFor.
3007
3179
  ...brain,
3008
3180
  // The tab watches the CLI work. Claude needs the flag to speak
@@ -3048,7 +3220,13 @@ export function createWorkManager({
3048
3220
  onSpawn: (ch) => {
3049
3221
  if (!ch) return;
3050
3222
  spawned.push(ch);
3051
- 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();
3052
3230
  // The CLI is spawned `detached`, so its pid IS its process
3053
3231
  // group id — and every process it starts inherits that, through
3054
3232
  // `nohup` and `setsid` alike. Remembered per SESSION rather
@@ -3222,6 +3400,10 @@ export function createWorkManager({
3222
3400
  warn(`session turn failed: ${e?.message ?? e}`);
3223
3401
  } finally {
3224
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();
3225
3407
  // The turn just changed the directory — say what it looks like now,
3226
3408
  // whether it succeeded or blew up (a failed turn can still have
3227
3409
  // written half a file, and the tab should show that honestly). NOT
@@ -3770,7 +3952,9 @@ export function createWorkManager({
3770
3952
  return [...out];
3771
3953
  };
3772
3954
 
3773
- 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 = () => {}) => {
3774
3958
  const id = String(job.id);
3775
3959
  const tasks = Array.isArray(job.tasks) ? job.tasks : [];
3776
3960
  // CLAIM BEFORE ANYTHING — including before the cheap refusal below.
@@ -3898,7 +4082,11 @@ export function createWorkManager({
3898
4082
  },
3899
4083
  onSpawn: (ch) => {
3900
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.
3901
4088
  workChildren.set(ch, null);
4089
+ releaseSlot();
3902
4090
  say(`${RUNTIMES[rt]?.label ?? rt} started on this machine`);
3903
4091
  /**
3904
4092
  * ARMED AT THE SPAWN, not at the claim: time spent waiting for the
@@ -3968,8 +4156,27 @@ export function createWorkManager({
3968
4156
  for (const job of jobs.slice(0, 1)) {
3969
4157
  const id = String(job?.id || '');
3970
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();
3971
4175
  planning.add(id);
3972
- void runAgentPlan(job).finally(() => planning.delete(id));
4176
+ void runAgentPlan(job, releaseSlot).finally(() => {
4177
+ releaseSlot();
4178
+ planning.delete(id);
4179
+ });
3973
4180
  }
3974
4181
  };
3975
4182
 
@@ -4063,6 +4270,42 @@ export function createWorkManager({
4063
4270
  }
4064
4271
  };
4065
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
+
4066
4309
  const postAgentActivity = async (agentId, text) => {
4067
4310
  try {
4068
4311
  await fetch(AGENT_ACTIVITY_URL, {
@@ -4145,7 +4388,10 @@ export function createWorkManager({
4145
4388
  : [];
4146
4389
  };
4147
4390
 
4148
- 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 = () => {}) => {
4149
4395
  const turnId = String(job.id);
4150
4396
  const agentId = String(job.agentId || '');
4151
4397
  const place = String(job.placeId || '');
@@ -4183,6 +4429,76 @@ export function createWorkManager({
4183
4429
  // places: those are agents' by construction, and anything else here would
4184
4430
  // be a tab's directory, where a turn is a reader by the product's own law.
4185
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
+ }
4186
4502
  const dir = placeWtFor(place);
4187
4503
  if (!dir) {
4188
4504
  // No worktree and none could be cut. `nothing` rather than an invented
@@ -4245,6 +4561,30 @@ export function createWorkManager({
4245
4561
  */
4246
4562
  const resume = rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
4247
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
+
4248
4588
  let out = '';
4249
4589
  let child = null;
4250
4590
  try {
@@ -4278,14 +4618,31 @@ export function createWorkManager({
4278
4618
  onActivity: (a) => {
4279
4619
  const line = a?.label;
4280
4620
  if (!line) return;
4621
+ // THE TRACE TAKES EVERY LINE; the pulse takes one every two
4622
+ // seconds. Two channels, one stream, and the drop-sampler stays a
4623
+ // drop-sampler — buffering the pulse would make a stale line look
4624
+ // fresh, which is the one thing it exists to answer.
4625
+ if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, line);
4281
4626
  const now = Date.now();
4282
4627
  if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
4283
4628
  lastAgentBeat.set(agentId, now);
4284
4629
  void postAgentActivity(agentId, envScrub(String(line)).slice(0, 400));
4285
4630
  },
4631
+ // The structured twin of the line above — the same `tool_use` the
4632
+ // Workbench's tool cards are built from, scrubbed at collection by
4633
+ // the builder itself (bounded window BEFORE its caps; see
4634
+ // toolEventOf). A tool it does not know pushes nothing.
4635
+ onToolEvent: (name, input) => {
4636
+ trace.tool(toolEventOf(name, input, wt, envScrub));
4637
+ },
4286
4638
  onSpawn: (ch) => {
4287
4639
  child = ch;
4288
- workChildren.set(ch, null);
4640
+ // The AGENT it serves — what the machine snapshot charges this
4641
+ // child's memory to.
4642
+ workChildren.set(ch, agentId);
4643
+ // …and the registry now counts what the reservation was standing
4644
+ // in for.
4645
+ releaseSlot();
4289
4646
  noteSessionGroup(agentId, ch.pid);
4290
4647
  // Keyed by PLACE, because the retire sweep iterates directory names
4291
4648
  // and a place id IS one. It is what lets a hard stop actually reach
@@ -4294,6 +4651,18 @@ export function createWorkManager({
4294
4651
  },
4295
4652
  });
4296
4653
  } finally {
4654
+ /**
4655
+ * THE TAIL, BEFORE THE SETTLE — so the last thing the agent did is on
4656
+ * the record by the time the board is told the turn is over.
4657
+ *
4658
+ * The server deliberately does NOT require a pending turn to accept a
4659
+ * trace batch (a late tail is still that turn's record), so a race here
4660
+ * is survivable rather than lossy; flushing first simply means it
4661
+ * almost never happens. Bounded, and the settle is what matters: an
4662
+ * uplink that will not answer costs the tail and nothing else.
4663
+ */
4664
+ trace.stop();
4665
+ await trace.flush(TRACE_FINAL_FLUSH_MS);
4297
4666
  if (child) workChildren.delete(child);
4298
4667
  if (agentChildren.get(place) === child) agentChildren.delete(place);
4299
4668
  if (ranMarker) {
@@ -4405,6 +4774,8 @@ export function createWorkManager({
4405
4774
  else if (now - held.at > AGENT_REPORT_GRACE_MS) agentReported.delete(id);
4406
4775
  }
4407
4776
  }
4777
+ /** One deferral line per tick, however many turns were offered. */
4778
+ let saidPressure = false;
4408
4779
  for (const job of list.slice(0, 4)) {
4409
4780
  const id = String(job?.id || '');
4410
4781
  if (!id || agentTurns.has(id)) continue;
@@ -4432,9 +4803,76 @@ export function createWorkManager({
4432
4803
  continue;
4433
4804
  }
4434
4805
  if (!job.agentId || !job.placeId) continue;
4806
+ /**
4807
+ * NOT NOW — and NOT SETTLED. An agent turn is the heaviest thing this
4808
+ * machine starts (a CLI with build permissions in its own worktree), and
4809
+ * four of them a tick with nothing looking at memory is how the daemon
4810
+ * froze somebody's computer.
4811
+ *
4812
+ * Deferring costs the job nothing: it is unleased, the server re-offers
4813
+ * it on the next poll, and no attempt is consumed. Settling it would be
4814
+ * the opposite — it would send the agent to Stuck over a turn this
4815
+ * machine never ran.
4816
+ *
4817
+ * Checked here rather than inside `runAgentTurn` so a HELD BODY above
4818
+ * still re-POSTs: that path spawns nothing, and holding a finished
4819
+ * turn's settle because the box is busy would park an agent for the
4820
+ * server's whole expiry. One LOG line per tick, not per job — a console
4821
+ * restating one unchanged fact four times is noise.
4822
+ *
4823
+ * The DECISION, though, is per job and has to be: spawns in this loop are
4824
+ * async, so `workChildren` cannot grow between iterations and four turns
4825
+ * would all be admitted against the same stale count. The reserved slot
4826
+ * is what the next iteration sees. See admission.mjs.
4827
+ */
4828
+ const hold = admit('churn');
4829
+ if (hold) {
4830
+ if (!saidPressure) {
4831
+ saidPressure = true;
4832
+ note(`${c.cyan('agent')} ${c.dim(`— holding off: ${hold.reason}`)}`);
4833
+ }
4834
+ continue;
4835
+ }
4836
+ const releaseSlot = admit.reserve();
4435
4837
  agentTurns.add(id);
4436
- void runAgentTurn(job).finally(() => agentTurns.delete(id));
4838
+ void runAgentTurn(job, releaseSlot).finally(() => {
4839
+ // Belt for every path that never reached a spawn — the release is
4840
+ // idempotent, so the normal case releases twice.
4841
+ releaseSlot();
4842
+ agentTurns.delete(id);
4843
+ });
4844
+ }
4845
+ };
4846
+
4847
+ /**
4848
+ * SETTLE EVERYTHING IN FLIGHT, because this process is about to go away.
4849
+ *
4850
+ * The one caller is the displacement stand-down: the project's machine moved
4851
+ * to another box, so nothing here will be re-offered to us and nothing else
4852
+ * knows these turns were running. An abandoned turn sits pending until the
4853
+ * server's six-hour expiry while the board shows an agent working on a machine
4854
+ * that has gone — the wedge every settle path in this lane exists to avoid.
4855
+ *
4856
+ * A HELD BODY OUTRANKS THE SENTENCE, and that is not an optimisation. A turn
4857
+ * whose CLI already FINISHED has a real answer queued (delivered, a question,
4858
+ * its commits); posting `nothing` over it would be this daemon lying about
4859
+ * work it actually did, and the settle is conditional on the row still being
4860
+ * pending, so whichever POST lands first is the one the board believes. The
4861
+ * held bodies are retried here for the same reason the commanded stop flushes
4862
+ * the tab queues: they exist only in this process.
4863
+ *
4864
+ * Bounded by what is in flight, and awaited by the caller behind a clock —
4865
+ * a wedged uplink must not hold the stand-down open.
4866
+ */
4867
+ const settleAgentTurns = async (sentence) => {
4868
+ const answer = String(sentence ?? '').slice(0, 1000);
4869
+ const posts = [];
4870
+ for (const turnId of agentTurns) {
4871
+ if (agentReported.has(turnId)) continue; // its own answer goes below
4872
+ posts.push(postAgentTurn({ turnId, outcome: 'nothing', answer }));
4437
4873
  }
4874
+ for (const [, held] of agentReported) posts.push(postAgentTurn(held.body));
4875
+ await Promise.allSettled(posts);
4438
4876
  };
4439
4877
 
4440
4878
  // ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
@@ -4529,7 +4967,7 @@ export function createWorkManager({
4529
4967
  * `groupKillChildren` for why this one is exempt from the
4530
4968
  * never-signal-the-group rule.
4531
4969
  */
4532
- workChildren.set(child, null);
4970
+ workChildren.set(child, agentId);
4533
4971
  groupKillChildren.add(child);
4534
4972
  } catch (e) {
4535
4973
  // TEXT BEFORE FINISH: `finish` captures `text` by value into the
@@ -5071,8 +5509,19 @@ export function createWorkManager({
5071
5509
  reportWorktrees,
5072
5510
  shutdownWork,
5073
5511
  workBusy,
5512
+ // The machine's own admission answer, and what it is counting. Handed to
5513
+ // the loop so the lanes fleet.mjs owns — the wiki cartographer — ask the
5514
+ // same question, and so the machine snapshot can charge memory to the work
5515
+ // holding it.
5516
+ admit,
5517
+ liveTurns,
5518
+ liveTurnCount,
5074
5519
  processAgentPlanJobs,
5075
5520
  processAgentTurnJobs,
5521
+ // Only the displacement stand-down calls this — see its comment. Exported
5522
+ // rather than hooked into `shutdownWork` because the signal handlers cannot
5523
+ // await, and a settle that is not awaited is a settle that did not happen.
5524
+ settleAgentTurns,
5076
5525
  processAgentMergeJobs,
5077
5526
  freshenManualPlaces,
5078
5527
  };