flowviant 0.75.0 → 0.76.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
@@ -28,7 +28,7 @@ import {
28
28
  mkdirSync,
29
29
  cpSync,
30
30
  } from 'node:fs';
31
- import { execFileSync } from 'node:child_process';
31
+ import { execFileSync, spawn } from 'node:child_process';
32
32
  import { join, dirname } from 'node:path';
33
33
  import {
34
34
  FLEET_URL,
@@ -44,6 +44,7 @@ import { listenersIn, measureListeners, listenersSupported } from './listeners.m
44
44
  import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
45
45
  import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
46
46
  import { createPlaceLock } from './placeLock.mjs';
47
+ import { parseProposal, parseTurnResult } from './agentPlan.mjs';
47
48
  import { sweepMergedBranch } from './shipSweep.mjs';
48
49
  import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
49
50
  import { openTunnel } from './preview.mjs';
@@ -54,9 +55,21 @@ import {
54
55
  WORK_TURN_KICKOFF,
55
56
  SYSTEM_WORK_PLAIN,
56
57
  WORK_TURN_KICKOFF_PLAIN,
58
+ SYSTEM_PLAN,
59
+ AGENT_PLAN_KICKOFF,
60
+ SYSTEM_AGENT,
61
+ AGENT_TASK_KICKOFF,
62
+ AGENT_HUMAN_KICKOFF,
57
63
  } from './prompts.mjs';
58
64
  import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
59
- import { detectRuntimes, canRun, recordSkills, toolEventOf, RUNTIMES } from './runtimes.mjs';
65
+ import {
66
+ detectRuntimes,
67
+ canRun,
68
+ pickRuntimeFor,
69
+ recordSkills,
70
+ toolEventOf,
71
+ RUNTIMES,
72
+ } from './runtimes.mjs';
60
73
 
61
74
  /** The place id meaning "the checkout", not a worktree. Must match the
62
75
  * server's REPO_PLACE — it is a wire value, not a local convention. */
@@ -142,6 +155,14 @@ export function createWorkManager({
142
155
  const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
143
156
  const PR_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-claim');
144
157
  const PR_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-done');
158
+ const AGENT_PLAN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-claim');
159
+ const AGENT_PLAN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-done');
160
+ const AGENT_TURN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-turn-done');
161
+ const AGENT_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-activity');
162
+ const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
163
+ const AGENT_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-check-done');
164
+ const AGENT_MERGE_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-claim');
165
+ const AGENT_MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-done');
145
166
  // What arrived on base, whichever road it took — observed after every beat
146
167
  // that can move origin/<base>: the sweep's fetch, a ship's push, a PR merge
147
168
  // this daemon performed. See landed.mjs for the seeding and delivery rules.
@@ -3375,8 +3396,868 @@ export function createWorkManager({
3375
3396
  * self-delete when a place goes quiet); the other collections are belt over
3376
3397
  * braces for the windows around it.
3377
3398
  */
3399
+ // ── AGENT PLAN JOBS: the Deploy press ──────────────────────────────────────
3400
+ //
3401
+ // Somebody selected cards and pressed Deploy. This turn works out HOW THE
3402
+ // WORK SHOULD BE SPLIT across agents and stops — a person edits what it
3403
+ // proposes on the board, and ACCEPTING is what spawns anything. Nothing here
3404
+ // creates a worktree, a branch or a card.
3405
+ //
3406
+ // READ-ONLY IN THE CHECKOUT. `readOnly: true` selects CONSULT_PERM (Read,
3407
+ // Grep, Glob and a few `git` reads — no Write, no Edit, no mkdir, no rm) and
3408
+ // NO MCP is passed at all, so this turn has no control plane to reach even if
3409
+ // the repository it reads tries to steer it. The proposal comes back as the
3410
+ // turn's final message rather than through a tool, which is exactly what lets
3411
+ // that permission set be this narrow.
3412
+ //
3413
+ // It takes the checkout's place lock as a READER, beside the operator's own
3414
+ // tabs. It writes nothing, so a writer lock would only starve real work.
3415
+ const planning = new Set(); // press ids in flight on this tick
3416
+
3417
+ const postAgentPlan = async (body) => {
3418
+ try {
3419
+ await fetch(AGENT_PLAN_DONE_URL, {
3420
+ method: 'POST',
3421
+ headers: {
3422
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3423
+ 'User-Agent': USER_AGENT,
3424
+ 'Content-Type': 'application/json',
3425
+ },
3426
+ signal: AbortSignal.timeout(30_000),
3427
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
3428
+ });
3429
+ } catch {
3430
+ /* unsettled, and the server expires it — the asker is told, never spun */
3431
+ }
3432
+ };
3433
+
3434
+ const claimAgentPlan = async (id) => {
3435
+ try {
3436
+ const res = await fetch(AGENT_PLAN_CLAIM_URL, {
3437
+ method: 'POST',
3438
+ headers: {
3439
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3440
+ 'User-Agent': USER_AGENT,
3441
+ 'Content-Type': 'application/json',
3442
+ },
3443
+ signal: AbortSignal.timeout(15_000),
3444
+ body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
3445
+ });
3446
+ const j = await res.json().catch(() => null);
3447
+ return j?.data?.claimed === true;
3448
+ } catch {
3449
+ return false; // the peer may hold it; doing nothing is the safe answer
3450
+ }
3451
+ };
3452
+
3453
+ /**
3454
+ * What a live agent has already TOUCHED, measured here rather than sent.
3455
+ *
3456
+ * The server could have shipped a copy of this, and deliberately does not:
3457
+ * these are directories on THIS machine, and a server-side copy would be a
3458
+ * second, staler source of truth for a fact the daemon is standing on top of.
3459
+ *
3460
+ * Uncommitted work AND commits against base, because a planner asking "will
3461
+ * these collide" cares about both — a file this agent has already rewritten
3462
+ * and merged into its own branch collides exactly as hard as one it is
3463
+ * editing now.
3464
+ */
3465
+ const agentChangedFiles = (placeId) => {
3466
+ if (!placeId || !isSafePathSegment(placeId)) return [];
3467
+ const wt = join(baseDir, 'sessions', placeId);
3468
+ if (!existsSync(wt)) return [];
3469
+ const out = new Set();
3470
+ for (const args of [
3471
+ ['diff', '--name-only', 'HEAD'],
3472
+ ['diff', '--name-only', `${baseRef()}...HEAD`],
3473
+ ['ls-files', '--others', '--exclude-standard'],
3474
+ ]) {
3475
+ const r = git(args, wt);
3476
+ if (typeof r !== 'string') continue;
3477
+ for (const line of r.split('\n')) {
3478
+ const f = line.trim();
3479
+ if (f) out.add(f);
3480
+ if (out.size >= 60) return [...out];
3481
+ }
3482
+ }
3483
+ return [...out];
3484
+ };
3485
+
3486
+ const runAgentPlan = async (job) => {
3487
+ const id = String(job.id);
3488
+ const tasks = Array.isArray(job.tasks) ? job.tasks : [];
3489
+ // CLAIM BEFORE ANYTHING — including before the cheap refusal below.
3490
+ //
3491
+ // The settle is rejected unless the caller HOLDS the lease, so posting an
3492
+ // error first meant the server discarded it and the press sat open forever,
3493
+ // holding its cards out of Deploy. That is the opposite of the ordering a
3494
+ // kill keeps (refuse cheaply, then claim) and it is because these two
3495
+ // lanes settle differently: a kill's settle does not check a lease for the
3496
+ // not-found case, and a plan's always does.
3497
+ if (!(await claimAgentPlan(id))) return;
3498
+ if (tasks.length === 0) {
3499
+ // The press named cards the doc no longer has. A real answer, and one the
3500
+ // board can explain, rather than a turn that plans nothing.
3501
+ await postAgentPlan({ id, error: 'those cards are no longer on the board' });
3502
+ return;
3503
+ }
3504
+
3505
+ /**
3506
+ * WHICH CLI PLANS. `pickRuntimeFor('consult')`, the same picker every other
3507
+ * turn nobody @mentioned already uses — Claude when it is here (the prompts
3508
+ * were written against it), otherwise whatever can express the profile.
3509
+ *
3510
+ * Deliberately NOT the project's runtime order: that order is about AGENTS,
3511
+ * which hold a conversation across many turns and whose value is that held
3512
+ * context. A planner runs once and reads; it takes what the machine has.
3513
+ */
3514
+ const rt = pickRuntimeFor('consult');
3515
+ if (!rt) {
3516
+ await postAgentPlan({ id, error: 'no CLI on this machine can run a read-only turn' });
3517
+ return;
3518
+ }
3519
+ const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map((a) => ({
3520
+ id: String(a?.id ?? ''),
3521
+ name: String(a?.name ?? ''),
3522
+ status: String(a?.status ?? ''),
3523
+ changedFiles: agentChangedFiles(a?.placeId),
3524
+ }));
3525
+
3526
+ let out = '';
3527
+ /** REMOVED IN A `finally`. `workChildren` is what `workBusy()` counts, and
3528
+ * a leaked entry keeps the daemon permanently "busy" — which blocks every
3529
+ * auto-update from that moment on, silently, until a restart. */
3530
+ let planChild = null;
3531
+ try {
3532
+ await inPlace(REPO_PLACE, false, async () => {
3533
+ out = await runTurn({
3534
+ prompt: AGENT_PLAN_KICKOFF({
3535
+ tasks,
3536
+ liveAgents,
3537
+ agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
3538
+ }),
3539
+ system: SYSTEM_PLAN,
3540
+ // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
3541
+ // passed empty, so there is no control plane on this turn at all.
3542
+ readOnly: true,
3543
+ cwd: repoRoot,
3544
+ runtime: rt,
3545
+ streamJson: true,
3546
+ answerFromResult: true,
3547
+ label: c.cyan('[plan]'),
3548
+ onSpawn: (ch) => {
3549
+ planChild = ch;
3550
+ workChildren.set(ch, null);
3551
+ },
3552
+ });
3553
+ });
3554
+ } catch (e) {
3555
+ await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500) });
3556
+ return;
3557
+ } finally {
3558
+ if (planChild) workChildren.delete(planChild);
3559
+ }
3560
+
3561
+ const proposal = parseProposal(out);
3562
+ if (!proposal) {
3563
+ await postAgentPlan({
3564
+ id,
3565
+ // The CLI's own words when it has any — a turn that explained why it
3566
+ // could not plan is far more use than "planning failed".
3567
+ error: out.trim()
3568
+ ? `the plan did not come back as JSON: ${envScrub(out).slice(0, 300)}`
3569
+ : 'the planning turn produced no output — the CLI may be signed out',
3570
+ });
3571
+ return;
3572
+ }
3573
+ await postAgentPlan({ id, proposal, sessionRef: repoRoot });
3574
+ };
3575
+
3576
+ const processAgentPlanJobs = (jobs) => {
3577
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
3578
+ // ONE AT A TIME, and the cap is 1 rather than 5: a planning turn is a real
3579
+ // model call, and a machine handed three at once would run three CLIs to
3580
+ // answer questions nobody asked in that order.
3581
+ for (const job of jobs.slice(0, 1)) {
3582
+ const id = String(job?.id || '');
3583
+ if (!id || planning.has(id)) continue;
3584
+ planning.add(id);
3585
+ void runAgentPlan(job).finally(() => planning.delete(id));
3586
+ }
3587
+ };
3588
+
3589
+ // ── AGENT TURNS: one task per prompt ───────────────────────────────────────
3590
+ //
3591
+ // An agent is one CLI in one worktree on one branch, working its cards ONE AT
3592
+ // A TIME. The server types the next prompt when this one lands; this side
3593
+ // does the work and reports what happened.
3594
+ //
3595
+ // NO MCP ON THIS TURN AT ALL. Everything the agent needs to say fits in its
3596
+ // final JSON object, and everything it needs to PROVE is measured from git
3597
+ // here afterwards — an agent naming its own commit shas would be a receipt
3598
+ // pointing at whatever it liked. That is also what keeps this feature from
3599
+ // adding a token kind to a scope map that has silently shipped empty twice.
3600
+ //
3601
+ // UNLEASED, unlike a plan or a kill. The server hands out at most one turn
3602
+ // per agent per poll and its settle is conditional on the row still being
3603
+ // pending, so a second daemon cannot advance the queue twice. What it could
3604
+ // do is run a CLI twice in one worktree, which is what this in-flight set and
3605
+ // the place lock prevent — the same discipline `processWorkTurns` keeps.
3606
+ const agentTurns = new Set(); // turn ids in flight on this tick
3607
+
3608
+ /** Returns the server's reply, because it carries ONE instruction the machine
3609
+ * can act on immediately: `review: true` means the agent's queue just
3610
+ * emptied, so run the project's own check in the worktree we are already
3611
+ * standing in. A job lane for that would need a claim, a floor and a settle
3612
+ * to say something this reply already can. */
3613
+ /** Turn ids whose work is DONE but whose report has not landed. The same
3614
+ * skip-guard `pendingWorkReports` is for a tab, and for the same reason: a
3615
+ * settle that fails to POST must not re-run the turn — that is a second CLI,
3616
+ * a second set of commits, and the operator's quota spent again. */
3617
+ const agentReported = new Set();
3618
+
3619
+ const postAgentTurn = async (body) => {
3620
+ agentReported.add(String(body.turnId));
3621
+ try {
3622
+ const res = await fetch(AGENT_TURN_DONE_URL, {
3623
+ method: 'POST',
3624
+ headers: {
3625
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3626
+ 'User-Agent': USER_AGENT,
3627
+ 'Content-Type': 'application/json',
3628
+ },
3629
+ signal: AbortSignal.timeout(30_000),
3630
+ body: JSON.stringify(body),
3631
+ });
3632
+ const j = await res.json().catch(() => null);
3633
+ // Landed. Forgetting it keeps the guard from growing without bound; a
3634
+ // re-offer of a settled turn is refused server-side anyway.
3635
+ if (res.ok) agentReported.delete(String(body.turnId));
3636
+ return j?.data ?? null;
3637
+ } catch {
3638
+ // Unsettled, and the id STAYS in the guard: the server expires the turn
3639
+ // and the agent lands in Stuck saying nobody ran it, which is far better
3640
+ // than running it again for six hours.
3641
+ return null;
3642
+ }
3643
+ };
3644
+
3645
+ const postAgentActivity = async (agentId, text) => {
3646
+ try {
3647
+ await fetch(AGENT_ACTIVITY_URL, {
3648
+ method: 'POST',
3649
+ headers: {
3650
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3651
+ 'User-Agent': USER_AGENT,
3652
+ 'Content-Type': 'application/json',
3653
+ },
3654
+ signal: AbortSignal.timeout(15_000),
3655
+ body: JSON.stringify({ agentId, text }),
3656
+ });
3657
+ } catch {
3658
+ /* narration is a readout; losing a line costs nothing */
3659
+ }
3660
+ };
3661
+
3662
+ /**
3663
+ * DID THE ACCOUNT HIT A LIMIT?
3664
+ *
3665
+ * Deliberately a LITERAL MATCH on the few sentences the CLIs actually print,
3666
+ * and the matched line is relayed VERBATIM. It is a trigger, not a
3667
+ * classifier: nothing here decides what an error "means" or writes a sentence
3668
+ * of its own, because the product's own rule is that it relays and never
3669
+ * infers. The honest limit is that a phrasing nobody listed reads as an
3670
+ * ordinary failed turn — which lands the agent in Stuck with the CLI's words
3671
+ * attached, and is a perfectly survivable second-best.
3672
+ */
3673
+ const LIMIT_PHRASES = [
3674
+ /usage limit reached/i,
3675
+ /rate limit/i,
3676
+ /you've reached your .* limit/i,
3677
+ /quota exceeded/i,
3678
+ /insufficient_quota/i,
3679
+ ];
3680
+ const limitLine = (text) => {
3681
+ for (const line of String(text ?? '').split('\n')) {
3682
+ const t = line.trim();
3683
+ if (t && LIMIT_PHRASES.some((re) => re.test(t))) return envScrub(t).slice(0, 300);
3684
+ }
3685
+ return null;
3686
+ };
3687
+
3688
+ /** Every sha that landed on this branch between two points. Measured, never
3689
+ * asserted — this is the whole reason an agent is not asked for its own. */
3690
+ const commitsBetween = (wt, from) => {
3691
+ if (!from) return [];
3692
+ /**
3693
+ * `--not <base>` and `--no-merges`, because `from..HEAD` alone reports
3694
+ * BASE'S commits as this turn's receipts the moment anything folds base in
3695
+ * — which the stale path does on purpose before a merge. A receipt naming
3696
+ * somebody else's commit is worse than a missing one.
3697
+ */
3698
+ const out = git(
3699
+ ['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()],
3700
+ wt
3701
+ );
3702
+ return typeof out === 'string'
3703
+ ? out.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50)
3704
+ : [];
3705
+ };
3706
+
3707
+ const runAgentTurn = async (job) => {
3708
+ const turnId = String(job.id);
3709
+ const agentId = String(job.agentId || '');
3710
+ const place = String(job.placeId || '');
3711
+ if (!isSafePathSegment(place)) {
3712
+ await postAgentTurn({ turnId, outcome: 'nothing' });
3713
+ return;
3714
+ }
3715
+
3716
+ await inPlace(place, false, async () => {
3717
+ const dir = placeWtFor(place);
3718
+ if (!dir) {
3719
+ // No worktree and none could be cut. `nothing` rather than an invented
3720
+ // error: the board says the machine went quiet, which is true.
3721
+ await postAgentTurn({ turnId, outcome: 'nothing' });
3722
+ return;
3723
+ }
3724
+ const wt = dir.wt;
3725
+ // WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
3726
+ // ones this turn actually made.
3727
+ const before = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
3728
+ const branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim() || null;
3729
+
3730
+ const rt = job.runtime || 'claude';
3731
+ if (!canRun(RUNTIMES[rt], 'build')) {
3732
+ await postAgentTurn({
3733
+ turnId,
3734
+ outcome: 'nothing',
3735
+ answer: `this machine cannot run ${rt}`,
3736
+ branch,
3737
+ worktree: wt,
3738
+ });
3739
+ return;
3740
+ }
3741
+
3742
+ // ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
3743
+ // right here — the ambiguity that forced per-tab session pinning in the
3744
+ // Workbench (many tabs, one place) cannot arise. The marker is what
3745
+ // distinguishes the first turn from every later one across restarts.
3746
+ const ranMarker = sessionMetaPath(wt, 'flowviant-agent-ran');
3747
+ /**
3748
+ * RESUME IS CLAUDE-ONLY, and that is a correctness rule rather than a
3749
+ * preference. Claude's `--continue` is CWD-keyed and one agent is one
3750
+ * directory, so it resumes exactly this agent. Codex's `resume --last` is
3751
+ * MACHINE-GLOBAL: it would cross-resume whichever conversation spoke most
3752
+ * recently anywhere on the box, which is the bug the Workbench fixed in
3753
+ * 0.69.0 by pinning per-tab ids. Until an agent pins its own thread id,
3754
+ * a codex agent starts fresh each turn — a worse turn, not a wrong one.
3755
+ */
3756
+ const resume = rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
3757
+
3758
+ let out = '';
3759
+ let child = null;
3760
+ try {
3761
+ out = await runTurn({
3762
+ prompt:
3763
+ job.kind === 'task' && job.task
3764
+ ? AGENT_TASK_KICKOFF({
3765
+ agentName: job.agentName,
3766
+ task: job.task,
3767
+ position: job.position ?? 1,
3768
+ total: job.total ?? 1,
3769
+ })
3770
+ : AGENT_HUMAN_KICKOFF({
3771
+ agentName: job.agentName,
3772
+ message: job.body ?? '',
3773
+ askedByName: job.askedByName,
3774
+ task: job.task,
3775
+ position: job.position ?? 1,
3776
+ total: job.total ?? 1,
3777
+ }),
3778
+ system: SYSTEM_AGENT,
3779
+ cwd: wt,
3780
+ runtime: rt,
3781
+ resume,
3782
+ streamJson: true,
3783
+ answerFromResult: true,
3784
+ label: c.cyan('[agent]'),
3785
+ // The CLI's own tail, relayed. Throttled by the same rule the tab's
3786
+ // narrator keeps: overwritten, never appended, and ignored by the
3787
+ // board past ~90 seconds.
3788
+ onActivity: (a) => {
3789
+ const line = a?.label;
3790
+ if (!line) return;
3791
+ const now = Date.now();
3792
+ if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
3793
+ lastAgentBeat.set(agentId, now);
3794
+ void postAgentActivity(agentId, envScrub(String(line)).slice(0, 400));
3795
+ },
3796
+ onSpawn: (ch) => {
3797
+ child = ch;
3798
+ workChildren.set(ch, null);
3799
+ noteSessionGroup(agentId, ch.pid);
3800
+ },
3801
+ });
3802
+ } finally {
3803
+ if (child) workChildren.delete(child);
3804
+ if (ranMarker) {
3805
+ try {
3806
+ writeFileSync(ranMarker, '1');
3807
+ } catch {
3808
+ /* a missing marker only costs one un-resumed turn */
3809
+ }
3810
+ }
3811
+ }
3812
+
3813
+ const commits = commitsBetween(wt, before);
3814
+ const res = parseTurnResult(out);
3815
+ /**
3816
+ * A LIMIT IS ONLY A LIMIT WHEN THE TURN PRODUCED NOTHING.
3817
+ *
3818
+ * `limitLine` is a literal phrase match over the CLI's output, and the
3819
+ * output of a successful turn contains whatever the agent wrote — so a
3820
+ * card about rate limiting, or a summary mentioning one, parked every
3821
+ * agent on the project. Gating on "the turn declared no outcome" is what
3822
+ * makes the match mean what it says: the CLI failed and this is the
3823
+ * sentence it failed with.
3824
+ */
3825
+ const limit = res ? null : limitLine(out);
3826
+ if (limit) {
3827
+ // EVERY agent parks, because the account is shared: one hitting the
3828
+ // limit means all of them have. The turn itself is reported as
3829
+ // `nothing` — it did not deliver and it did not ask.
3830
+ await postAgentParked(limit);
3831
+ await postAgentTurn({ turnId, outcome: 'nothing', answer: limit, branch, worktree: wt });
3832
+ return;
3833
+ }
3834
+
3835
+ if (!res) {
3836
+ await postAgentTurn({
3837
+ turnId,
3838
+ outcome: 'nothing',
3839
+ // The CLI's own words when it produced any. A turn that explained why
3840
+ // it stopped is far more use than "the agent stopped".
3841
+ answer: out.trim()
3842
+ ? envScrub(out).slice(-1500)
3843
+ : 'the turn produced no output on the machine — its CLI may be signed out',
3844
+ ...(commits.length ? { commits } : {}),
3845
+ branch,
3846
+ worktree: wt,
3847
+ });
3848
+ return;
3849
+ }
3850
+ const reply = await postAgentTurn({
3851
+ turnId,
3852
+ outcome: res.outcome,
3853
+ answer: envScrub(res.answer ?? '').slice(0, 8000),
3854
+ ...(commits.length ? { commits } : {}),
3855
+ ...(res.raised?.length ? { raised: res.raised } : {}),
3856
+ branch,
3857
+ worktree: wt,
3858
+ });
3859
+ // The queue just emptied. Run the project's own check HERE, in the
3860
+ // worktree we are already standing in and still hold the lock on.
3861
+ if (reply?.review === true) await runCheck(agentId, wt);
3862
+ });
3863
+ };
3864
+
3865
+ const lastAgentBeat = new Map(); // agentId -> last activity POST, ms
3866
+
3867
+ const postAgentParked = async (reason) => {
3868
+ try {
3869
+ await fetch(AGENT_PARKED_URL, {
3870
+ method: 'POST',
3871
+ headers: {
3872
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3873
+ 'User-Agent': USER_AGENT,
3874
+ 'Content-Type': 'application/json',
3875
+ },
3876
+ signal: AbortSignal.timeout(15_000),
3877
+ body: JSON.stringify({ reason }),
3878
+ });
3879
+ } catch {
3880
+ /* the next turn will hit the same limit and try again */
3881
+ }
3882
+ };
3883
+
3884
+ const processAgentTurnJobs = (jobs) => {
3885
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
3886
+ for (const job of jobs.slice(0, 4)) {
3887
+ const id = String(job?.id || '');
3888
+ if (!id || agentTurns.has(id)) continue;
3889
+ // Already RAN here; only the report is outstanding. Re-running it would
3890
+ // spend the operator's quota again and write a second set of commits.
3891
+ if (agentReported.has(id)) continue;
3892
+ if (!job.agentId || !job.placeId) continue;
3893
+ agentTurns.add(id);
3894
+ void runAgentTurn(job).finally(() => agentTurns.delete(id));
3895
+ }
3896
+ };
3897
+
3898
+ // ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
3899
+ //
3900
+ // The check runs in the agent's own worktree the moment its queue empties, so
3901
+ // a reviewer knows before they start reading whether they are reviewing
3902
+ // working code. It LABELS the review row; it never blocks it.
3903
+ //
3904
+ // IT IS THE REPO'S COMMAND, DECLARED IN THE REPO. `.flowviant/check.json`,
3905
+ // beside `deploy.json`, because a check travels with the code and changes
3906
+ // with it — a setting in the app would go stale the first time somebody
3907
+ // renamed a script. An absent file is a MEASURED answer ('none'), not a nag:
3908
+ // plenty of projects have no single command that means "is this alright".
3909
+ //
3910
+ // It runs through a shell, and that is no wider than what already happens in
3911
+ // that directory: every turn in this worktree spawns a CLI with build
3912
+ // permissions, so a repository that can run arbitrary code during a turn can
3913
+ // run it here too. What this is NOT is the deleted dev-run supervisor —
3914
+ // nothing here resolves a command, guesses a stack, or starts a server.
3915
+ const CHECK_TIMEOUT_MS = 10 * 60_000;
3916
+ const CHECK_OUTPUT_CAP = 4000;
3917
+
3918
+ const readCheckCommand = () => {
3919
+ try {
3920
+ const raw = readFileSync(join(repoRoot, '.flowviant', 'check.json'), 'utf8');
3921
+ const cfg = JSON.parse(raw);
3922
+ const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
3923
+ return cmd ? cmd.slice(0, 500) : null;
3924
+ } catch {
3925
+ return null; // absent, unreadable or not JSON — all mean "no check"
3926
+ }
3927
+ };
3928
+
3929
+ const postCheck = async (body) => {
3930
+ try {
3931
+ await fetch(AGENT_CHECK_DONE_URL, {
3932
+ method: 'POST',
3933
+ headers: {
3934
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3935
+ 'User-Agent': USER_AGENT,
3936
+ 'Content-Type': 'application/json',
3937
+ },
3938
+ signal: AbortSignal.timeout(30_000),
3939
+ body: JSON.stringify(body),
3940
+ });
3941
+ } catch {
3942
+ /* the row simply keeps its previous answer, which is null the first time */
3943
+ }
3944
+ };
3945
+
3946
+ const runCheck = async (agentId, wt) => {
3947
+ const cmd = readCheckCommand();
3948
+ const headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || undefined;
3949
+ if (!cmd) {
3950
+ await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
3951
+ return;
3952
+ }
3953
+ const out = await new Promise((resolve) => {
3954
+ let text = '';
3955
+ let done = false;
3956
+ const finish = (status) => {
3957
+ if (done) return;
3958
+ done = true;
3959
+ resolve({ status, text });
3960
+ };
3961
+ let child;
3962
+ try {
3963
+ // DETACHED, so the child's pid is its PROCESS GROUP. A check is almost
3964
+ // always a shell that spawns the real runner, and signalling the shell
3965
+ // alone leaves the runner holding the worktree — and this place's
3966
+ // WRITER lock — for as long as it likes.
3967
+ child = spawn(cmd, {
3968
+ cwd: wt,
3969
+ shell: true,
3970
+ detached: true,
3971
+ stdio: ['ignore', 'pipe', 'pipe'],
3972
+ });
3973
+ } catch (e) {
3974
+ // TEXT BEFORE FINISH: `finish` captures `text` by value into the
3975
+ // resolved object, so assigning afterwards threw the spawn error away
3976
+ // and the surface showed an empty failure.
3977
+ text = String(e?.message || e);
3978
+ finish('failed');
3979
+ return;
3980
+ }
3981
+ // The TAIL, not the head: a failing check says why at the end.
3982
+ const keep = (buf) => {
3983
+ text = (text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
3984
+ };
3985
+ child.stdout?.on('data', keep);
3986
+ child.stderr?.on('data', keep);
3987
+ const timer = setTimeout(() => {
3988
+ try {
3989
+ // The GROUP, not the child: killing the shell leaves whatever it
3990
+ // started running, which is the thing actually taking ten minutes.
3991
+ process.kill(-child.pid, 'SIGKILL');
3992
+ } catch {
3993
+ try {
3994
+ child.kill('SIGKILL');
3995
+ } catch {
3996
+ /* already gone */
3997
+ }
3998
+ }
3999
+ text += '\n[flowviant] the check ran past ten minutes and was stopped';
4000
+ // RESOLVE HERE TOO. Waiting for 'close' after a kill is the shape that
4001
+ // hangs: if the group is already gone the event never arrives, and this
4002
+ // promise holds the place's writer lock forever.
4003
+ finish('failed');
4004
+ }, CHECK_TIMEOUT_MS);
4005
+ child.on('error', (e) => {
4006
+ clearTimeout(timer);
4007
+ text += String(e?.message || e);
4008
+ finish('failed');
4009
+ });
4010
+ child.on('close', (code) => {
4011
+ clearTimeout(timer);
4012
+ finish(code === 0 ? 'passed' : 'failed');
4013
+ });
4014
+ });
4015
+ await postCheck({
4016
+ agentId,
4017
+ status: out.status,
4018
+ output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
4019
+ ...(headSha ? { headSha } : {}),
4020
+ });
4021
+ };
4022
+
4023
+ // ── THE MERGE ──────────────────────────────────────────────────────────────
4024
+ //
4025
+ // LEASED, because two `git merge --no-ff` and two pushes over one branch is
4026
+ // the loudest duplicate this system can produce. It reuses `mergeOutward`
4027
+ // verbatim — the same throwaway-worktree merge, the same once-only retry when
4028
+ // two people land at the same moment — because an agent's branch is not
4029
+ // special: it is a branch, and this repo already knows how to land one.
4030
+ //
4031
+ // A SUCCESS CLOSES NOTHING. Done stays OBSERVED: the merge reaches base, the
4032
+ // landed observer's own fetch sees it, and the cards close there.
4033
+ const agentMerges = new Set(); // agent ids in flight on this tick
4034
+
4035
+ const claimAgentMerge = async (agentId) => {
4036
+ try {
4037
+ const res = await fetch(AGENT_MERGE_CLAIM_URL, {
4038
+ method: 'POST',
4039
+ headers: {
4040
+ Authorization: `Bearer ${FLEET_TOKEN}`,
4041
+ 'User-Agent': USER_AGENT,
4042
+ 'Content-Type': 'application/json',
4043
+ },
4044
+ signal: AbortSignal.timeout(15_000),
4045
+ body: JSON.stringify({ agentId, instance: DAEMON_INSTANCE }),
4046
+ });
4047
+ const j = await res.json().catch(() => null);
4048
+ return j?.data?.claimed === true;
4049
+ } catch {
4050
+ return false; // the peer may hold it; doing nothing is the safe answer
4051
+ }
4052
+ };
4053
+
4054
+ const postAgentMerge = async (body) => {
4055
+ try {
4056
+ await fetch(AGENT_MERGE_DONE_URL, {
4057
+ method: 'POST',
4058
+ headers: {
4059
+ Authorization: `Bearer ${FLEET_TOKEN}`,
4060
+ 'User-Agent': USER_AGENT,
4061
+ 'Content-Type': 'application/json',
4062
+ },
4063
+ signal: AbortSignal.timeout(30_000),
4064
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
4065
+ });
4066
+ } catch {
4067
+ /* the lease lapses and the job is re-offered — a merge is idempotent
4068
+ against an already-merged branch, which mergeOutward detects */
4069
+ }
4070
+ };
4071
+
4072
+ /**
4073
+ * A merge COMMIT needs a git identity and the machine may have none. Prefer
4074
+ * the operator's own config; fall back to the daemon's, the same fallback
4075
+ * `checkpointWip` and ship both use, so a bare machine does not fail the fold
4076
+ * with "Please tell me who you are".
4077
+ */
4078
+ const gitMerge = (args, cwd) => {
4079
+ let idEnv = null;
4080
+ try {
4081
+ git(['config', 'user.email'], repoRoot);
4082
+ } catch {
4083
+ idEnv = {
4084
+ GIT_AUTHOR_NAME: 'Flowviant',
4085
+ GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
4086
+ GIT_COMMITTER_NAME: 'Flowviant',
4087
+ GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
4088
+ };
4089
+ }
4090
+ return execFileSync('git', args, {
4091
+ cwd,
4092
+ encoding: 'utf8',
4093
+ stdio: ['ignore', 'pipe', 'pipe'],
4094
+ ...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
4095
+ });
4096
+ };
4097
+
4098
+ const runAgentMerge = async (job) => {
4099
+ const agentId = String(job.agentId);
4100
+ const place = String(job.placeId || '');
4101
+ if (!isSafePathSegment(place)) {
4102
+ await postAgentMerge({ agentId, ok: false, detail: 'the agent has no worktree here' });
4103
+ return;
4104
+ }
4105
+ if (!(await claimAgentMerge(agentId))) return;
4106
+
4107
+ // A WRITER on the place, exactly as a ship is. It folds base in and pushes
4108
+ // with git in that directory, and no CLI can coordinate with something it
4109
+ // does not know exists.
4110
+ await inPlace(place, true, async () => {
4111
+ const wt = join(baseDir, 'sessions', place);
4112
+ if (!existsSync(wt)) {
4113
+ await postAgentMerge({ agentId, ok: false, detail: 'the worktree is gone' });
4114
+ return;
4115
+ }
4116
+ try {
4117
+ git(['fetch', 'origin', '--quiet'], repoRoot);
4118
+ } catch {
4119
+ /* offline — the merge fails honestly below */
4120
+ }
4121
+ // STALE means base moved under this branch while it sat in review. Fold
4122
+ // base IN first so the merge that follows is against what is actually
4123
+ // there; a conflict here is the same conflict the merge would hit, found
4124
+ // one step earlier and in the agent's own directory where it can be
4125
+ // resolved.
4126
+ if (job.stale) {
4127
+ try {
4128
+ gitMerge(['merge', '--no-edit', baseRef()], wt);
4129
+ } catch (e) {
4130
+ await postAgentMerge({
4131
+ agentId,
4132
+ ok: false,
4133
+ detail: envScrub(String(e?.message || e)).slice(0, 2000),
4134
+ });
4135
+ return;
4136
+ }
4137
+ // The branch changed, so the previous check answered about a different
4138
+ // tree. Re-run it before anything merges.
4139
+ await runCheck(agentId, wt);
4140
+ }
4141
+ // `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
4142
+ // a detached HEAD — so the guard below was unreachable and the throw
4143
+ // escaped the claimed merge, leaving it unsettled until its lease lapsed.
4144
+ let branch = '';
4145
+ try {
4146
+ branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim();
4147
+ } catch {
4148
+ branch = '';
4149
+ }
4150
+ if (!branch) {
4151
+ // A detached HEAD names no branch, so there is nothing to merge and
4152
+ // nothing to record. An ambiguity in git, not a rule of ours.
4153
+ await postAgentMerge({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
4154
+ return;
4155
+ }
4156
+ const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
4157
+ const countOut = git(['rev-list', '--count', `${baseRef()}..HEAD`], wt);
4158
+ const count = Number((countOut || '0').trim()) || 0;
4159
+ if (count === 0) {
4160
+ // Already on base — an idempotent re-offer, or an agent that changed
4161
+ // nothing. Reported as a SUCCESS: the branch's work is on base, which
4162
+ // is what the caller is asking about.
4163
+ await postAgentMerge({ agentId, ok: true, sha: tip || undefined });
4164
+ return;
4165
+ }
4166
+ try {
4167
+ shipMergeOutward({
4168
+ tip,
4169
+ count,
4170
+ branch,
4171
+ label: job.agentName || place.slice(0, 12),
4172
+ git,
4173
+ gitMerge,
4174
+ repoRoot,
4175
+ tmpDir: join(baseDir, 'ship', place),
4176
+ baseRef,
4177
+ workingTree: wt,
4178
+ warn,
4179
+ });
4180
+ } catch (e) {
4181
+ await postAgentMerge({
4182
+ agentId,
4183
+ ok: false,
4184
+ detail: envScrub(String(e?.message || e)).slice(0, 2000),
4185
+ });
4186
+ return;
4187
+ }
4188
+ await postAgentMerge({ agentId, ok: true, sha: tip });
4189
+ onRepoChanged();
4190
+ landed.observe();
4191
+ });
4192
+ };
4193
+
4194
+ const processAgentMergeJobs = (jobs) => {
4195
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
4196
+ for (const job of jobs.slice(0, 2)) {
4197
+ const id = String(job?.agentId || '');
4198
+ if (!id || agentMerges.has(id)) continue;
4199
+ agentMerges.add(id);
4200
+ void runAgentMerge(job).finally(() => agentMerges.delete(id));
4201
+ }
4202
+ };
4203
+
4204
+ /**
4205
+ * KEEP EACH PERSON'S MANUAL WORKTREE FRESH.
4206
+ *
4207
+ * Every teammate's Workbench tabs share one directory of their own on a
4208
+ * branch of their own — which is not a preference, it is what git allows:
4209
+ * two worktrees cannot have the same branch checked out, so "everyone works
4210
+ * on main" is only true for the machine's OPERATOR, whose place is the
4211
+ * checkout itself. Everyone else needs a branch, and a branch left alone
4212
+ * drifts behind main until the first thing they do in a new tab is a merge
4213
+ * they did not ask for.
4214
+ *
4215
+ * FAST-FORWARD ONLY, and that is the whole safety argument. If their branch
4216
+ * has no commits of its own it simply catches up, which is the ordinary case
4217
+ * and the one worth automating. The moment it HAS diverged, this stops and
4218
+ * leaves it exactly as it is: their commits are theirs, a rebase would
4219
+ * rewrite them under somebody who is not looking, and a merge would put a
4220
+ * commit in their history that they did not make. Their own Claude can fold
4221
+ * base in whenever they ask it to.
4222
+ *
4223
+ * Guarded three ways: a DIRTY tree is left alone (uncommitted work outranks
4224
+ * freshness), a place with a live lock is skipped (a turn is standing in it),
4225
+ * and the whole thing is silent — nothing here reports, warns or blocks.
4226
+ */
4227
+ const freshenManualPlaces = () => {
4228
+ let dir;
4229
+ try {
4230
+ dir = readdirSync(join(baseDir, 'sessions'));
4231
+ } catch {
4232
+ return; // no worktrees yet
4233
+ }
4234
+ for (const place of dir) {
4235
+ // Only a PERSON's manual place. An agent's own worktree (`a-<id>`) is
4236
+ // deliberately not touched: its branch is the reviewable unit, and
4237
+ // moving it under a review would change what somebody is deciding about.
4238
+ if (!place.startsWith('u-') || !isSafePathSegment(place)) continue;
4239
+ if (placeLocks.has(place)) continue;
4240
+ const wt = join(baseDir, 'sessions', place);
4241
+ try {
4242
+ if (git(['status', '--porcelain'], wt).trim() !== '') continue;
4243
+ git(['merge', '--ff-only', baseRef()], wt);
4244
+ } catch {
4245
+ /* diverged, or something else is going on in there. Leave it. */
4246
+ }
4247
+ }
4248
+ };
4249
+
3378
4250
  const workBusy = () =>
3379
4251
  placeLocks.size > 0 ||
4252
+ // A planning turn is a live CLI child of ours, and an auto-update that
4253
+ // SIGTERMs it mid-flight would leave a press claimed, unsettled and
4254
+ // waiting out its lease while somebody watches a spinner.
4255
+ planning.size > 0 ||
4256
+ // An agent turn is real work in a real worktree. Killed mid-flight it
4257
+ // leaves uncommitted edits and a turn the server will eventually expire
4258
+ // into a card nobody can explain.
4259
+ agentTurns.size > 0 ||
4260
+ agentMerges.size > 0 ||
3380
4261
  shipping.size > 0 ||
3381
4262
  workChildren.size > 0 ||
3382
4263
  workAnswering.size > 0 ||
@@ -3400,5 +4281,9 @@ export function createWorkManager({
3400
4281
  reportWorktrees,
3401
4282
  shutdownWork,
3402
4283
  workBusy,
4284
+ processAgentPlanJobs,
4285
+ processAgentTurnJobs,
4286
+ processAgentMergeJobs,
4287
+ freshenManualPlaces,
3403
4288
  };
3404
4289
  }