flowviant 0.75.0 → 0.77.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,997 @@ 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
+ * A TASK TURN WITH NO CARD IS REFUSED, NOT IMPROVISED.
3717
+ *
3718
+ * The kickoff below branches on `job.kind === 'task' && job.task` and falls
3719
+ * through to the HUMAN kickoff otherwise — and a task turn's body is empty
3720
+ * by design, because the daemon composes the prompt from the card's own
3721
+ * spec. So a card deleted while its agent held it spawned a real CLI on a
3722
+ * prompt whose entire content was "a member of this project said: (nothing).
3723
+ * Carry on, and end with the JSON object." Nothing on either side refused
3724
+ * it, and an ordinary gesture reached it: the board's Delete.
3725
+ *
3726
+ * The server now declines to hand out such a job at all. This is the
3727
+ * backstop, and it is worth having on its own: it costs one comparison and
3728
+ * it is the layer that cannot be skipped by a server on an older deploy.
3729
+ * `nothing` is the honest outcome — the agent goes to Stuck saying the turn
3730
+ * produced nothing, which is exactly what happened.
3731
+ */
3732
+ if (job.kind === 'task' && !job.task) {
3733
+ await postAgentTurn({
3734
+ turnId,
3735
+ outcome: 'nothing',
3736
+ answer: 'the card this turn was for no longer exists',
3737
+ });
3738
+ return;
3739
+ }
3740
+
3741
+ await inPlace(place, false, async () => {
3742
+ const dir = placeWtFor(place);
3743
+ if (!dir) {
3744
+ // No worktree and none could be cut. `nothing` rather than an invented
3745
+ // error: the board says the machine went quiet, which is true.
3746
+ await postAgentTurn({ turnId, outcome: 'nothing' });
3747
+ return;
3748
+ }
3749
+ const wt = dir.wt;
3750
+ // WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
3751
+ // ones this turn actually made.
3752
+ const before = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
3753
+ const branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim() || null;
3754
+
3755
+ const rt = job.runtime || 'claude';
3756
+ if (!canRun(RUNTIMES[rt], 'build')) {
3757
+ await postAgentTurn({
3758
+ turnId,
3759
+ outcome: 'nothing',
3760
+ answer: `this machine cannot run ${rt}`,
3761
+ branch,
3762
+ worktree: wt,
3763
+ });
3764
+ return;
3765
+ }
3766
+
3767
+ // ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
3768
+ // right here — the ambiguity that forced per-tab session pinning in the
3769
+ // Workbench (many tabs, one place) cannot arise. The marker is what
3770
+ // distinguishes the first turn from every later one across restarts.
3771
+ const ranMarker = sessionMetaPath(wt, 'flowviant-agent-ran');
3772
+ /**
3773
+ * RESUME IS CLAUDE-ONLY, and that is a correctness rule rather than a
3774
+ * preference. Claude's `--continue` is CWD-keyed and one agent is one
3775
+ * directory, so it resumes exactly this agent. Codex's `resume --last` is
3776
+ * MACHINE-GLOBAL: it would cross-resume whichever conversation spoke most
3777
+ * recently anywhere on the box, which is the bug the Workbench fixed in
3778
+ * 0.69.0 by pinning per-tab ids. Until an agent pins its own thread id,
3779
+ * a codex agent starts fresh each turn — a worse turn, not a wrong one.
3780
+ */
3781
+ const resume = rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
3782
+
3783
+ let out = '';
3784
+ let child = null;
3785
+ try {
3786
+ out = await runTurn({
3787
+ prompt:
3788
+ job.kind === 'task' && job.task
3789
+ ? AGENT_TASK_KICKOFF({
3790
+ agentName: job.agentName,
3791
+ task: job.task,
3792
+ position: job.position ?? 1,
3793
+ total: job.total ?? 1,
3794
+ })
3795
+ : AGENT_HUMAN_KICKOFF({
3796
+ agentName: job.agentName,
3797
+ message: job.body ?? '',
3798
+ askedByName: job.askedByName,
3799
+ task: job.task,
3800
+ position: job.position ?? 1,
3801
+ total: job.total ?? 1,
3802
+ }),
3803
+ system: SYSTEM_AGENT,
3804
+ cwd: wt,
3805
+ runtime: rt,
3806
+ resume,
3807
+ streamJson: true,
3808
+ answerFromResult: true,
3809
+ label: c.cyan('[agent]'),
3810
+ // The CLI's own tail, relayed. Throttled by the same rule the tab's
3811
+ // narrator keeps: overwritten, never appended, and ignored by the
3812
+ // board past ~90 seconds.
3813
+ onActivity: (a) => {
3814
+ const line = a?.label;
3815
+ if (!line) return;
3816
+ const now = Date.now();
3817
+ if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
3818
+ lastAgentBeat.set(agentId, now);
3819
+ void postAgentActivity(agentId, envScrub(String(line)).slice(0, 400));
3820
+ },
3821
+ onSpawn: (ch) => {
3822
+ child = ch;
3823
+ workChildren.set(ch, null);
3824
+ noteSessionGroup(agentId, ch.pid);
3825
+ },
3826
+ });
3827
+ } finally {
3828
+ if (child) workChildren.delete(child);
3829
+ if (ranMarker) {
3830
+ try {
3831
+ writeFileSync(ranMarker, '1');
3832
+ } catch {
3833
+ /* a missing marker only costs one un-resumed turn */
3834
+ }
3835
+ }
3836
+ }
3837
+
3838
+ const commits = commitsBetween(wt, before);
3839
+ const res = parseTurnResult(out);
3840
+ /**
3841
+ * A LIMIT IS ONLY A LIMIT WHEN THE TURN PRODUCED NOTHING.
3842
+ *
3843
+ * `limitLine` is a literal phrase match over the CLI's output, and the
3844
+ * output of a successful turn contains whatever the agent wrote — so a
3845
+ * card about rate limiting, or a summary mentioning one, parked every
3846
+ * agent on the project. Gating on "the turn declared no outcome" is what
3847
+ * makes the match mean what it says: the CLI failed and this is the
3848
+ * sentence it failed with.
3849
+ */
3850
+ const limit = res ? null : limitLine(out);
3851
+ if (limit) {
3852
+ // EVERY agent parks, because the account is shared: one hitting the
3853
+ // limit means all of them have. The turn itself is reported as
3854
+ // `nothing` — it did not deliver and it did not ask.
3855
+ await postAgentParked(limit);
3856
+ await postAgentTurn({ turnId, outcome: 'nothing', answer: limit, branch, worktree: wt });
3857
+ return;
3858
+ }
3859
+
3860
+ if (!res) {
3861
+ await postAgentTurn({
3862
+ turnId,
3863
+ outcome: 'nothing',
3864
+ // The CLI's own words when it produced any. A turn that explained why
3865
+ // it stopped is far more use than "the agent stopped".
3866
+ answer: out.trim()
3867
+ ? envScrub(out).slice(-1500)
3868
+ : 'the turn produced no output on the machine — its CLI may be signed out',
3869
+ ...(commits.length ? { commits } : {}),
3870
+ branch,
3871
+ worktree: wt,
3872
+ });
3873
+ return;
3874
+ }
3875
+ const reply = await postAgentTurn({
3876
+ turnId,
3877
+ outcome: res.outcome,
3878
+ answer: envScrub(res.answer ?? '').slice(0, 8000),
3879
+ ...(commits.length ? { commits } : {}),
3880
+ ...(res.raised?.length ? { raised: res.raised } : {}),
3881
+ branch,
3882
+ worktree: wt,
3883
+ });
3884
+ // The queue just emptied. Run the project's own check HERE, in the
3885
+ // worktree we are already standing in and still hold the lock on.
3886
+ if (reply?.review === true) await runCheck(agentId, wt);
3887
+ });
3888
+ };
3889
+
3890
+ const lastAgentBeat = new Map(); // agentId -> last activity POST, ms
3891
+
3892
+ const postAgentParked = async (reason) => {
3893
+ try {
3894
+ await fetch(AGENT_PARKED_URL, {
3895
+ method: 'POST',
3896
+ headers: {
3897
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3898
+ 'User-Agent': USER_AGENT,
3899
+ 'Content-Type': 'application/json',
3900
+ },
3901
+ signal: AbortSignal.timeout(15_000),
3902
+ body: JSON.stringify({ reason }),
3903
+ });
3904
+ } catch {
3905
+ /* the next turn will hit the same limit and try again */
3906
+ }
3907
+ };
3908
+
3909
+ const processAgentTurnJobs = (jobs) => {
3910
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
3911
+ for (const job of jobs.slice(0, 4)) {
3912
+ const id = String(job?.id || '');
3913
+ if (!id || agentTurns.has(id)) continue;
3914
+ // Already RAN here; only the report is outstanding. Re-running it would
3915
+ // spend the operator's quota again and write a second set of commits.
3916
+ if (agentReported.has(id)) continue;
3917
+ if (!job.agentId || !job.placeId) continue;
3918
+ agentTurns.add(id);
3919
+ void runAgentTurn(job).finally(() => agentTurns.delete(id));
3920
+ }
3921
+ };
3922
+
3923
+ // ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
3924
+ //
3925
+ // The check runs in the agent's own worktree the moment its queue empties, so
3926
+ // a reviewer knows before they start reading whether they are reviewing
3927
+ // working code. It LABELS the review row; it never blocks it.
3928
+ //
3929
+ // IT IS THE REPO'S COMMAND, DECLARED IN THE REPO. `.flowviant/check.json`,
3930
+ // beside `deploy.json`, because a check travels with the code and changes
3931
+ // with it — a setting in the app would go stale the first time somebody
3932
+ // renamed a script. An absent file is a MEASURED answer ('none'), not a nag:
3933
+ // plenty of projects have no single command that means "is this alright".
3934
+ //
3935
+ // It runs through a shell, and that is no wider than what already happens in
3936
+ // that directory: every turn in this worktree spawns a CLI with build
3937
+ // permissions, so a repository that can run arbitrary code during a turn can
3938
+ // run it here too. What this is NOT is the deleted dev-run supervisor —
3939
+ // nothing here resolves a command, guesses a stack, or starts a server.
3940
+ const CHECK_TIMEOUT_MS = 10 * 60_000;
3941
+ const CHECK_OUTPUT_CAP = 4000;
3942
+
3943
+ const readCheckCommand = () => {
3944
+ try {
3945
+ const raw = readFileSync(join(repoRoot, '.flowviant', 'check.json'), 'utf8');
3946
+ const cfg = JSON.parse(raw);
3947
+ const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
3948
+ return cmd ? cmd.slice(0, 500) : null;
3949
+ } catch {
3950
+ return null; // absent, unreadable or not JSON — all mean "no check"
3951
+ }
3952
+ };
3953
+
3954
+ const postCheck = async (body) => {
3955
+ try {
3956
+ await fetch(AGENT_CHECK_DONE_URL, {
3957
+ method: 'POST',
3958
+ headers: {
3959
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3960
+ 'User-Agent': USER_AGENT,
3961
+ 'Content-Type': 'application/json',
3962
+ },
3963
+ signal: AbortSignal.timeout(30_000),
3964
+ body: JSON.stringify(body),
3965
+ });
3966
+ } catch {
3967
+ /* the row simply keeps its previous answer, which is null the first time */
3968
+ }
3969
+ };
3970
+
3971
+ const runCheck = async (agentId, wt) => {
3972
+ const cmd = readCheckCommand();
3973
+ const headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || undefined;
3974
+ if (!cmd) {
3975
+ await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
3976
+ return;
3977
+ }
3978
+ const out = await new Promise((resolve) => {
3979
+ let text = '';
3980
+ let done = false;
3981
+ const finish = (status) => {
3982
+ if (done) return;
3983
+ done = true;
3984
+ resolve({ status, text });
3985
+ };
3986
+ let child;
3987
+ try {
3988
+ // DETACHED, so the child's pid is its PROCESS GROUP. A check is almost
3989
+ // always a shell that spawns the real runner, and signalling the shell
3990
+ // alone leaves the runner holding the worktree — and this place's
3991
+ // WRITER lock — for as long as it likes.
3992
+ child = spawn(cmd, {
3993
+ cwd: wt,
3994
+ shell: true,
3995
+ detached: true,
3996
+ stdio: ['ignore', 'pipe', 'pipe'],
3997
+ });
3998
+ } catch (e) {
3999
+ // TEXT BEFORE FINISH: `finish` captures `text` by value into the
4000
+ // resolved object, so assigning afterwards threw the spawn error away
4001
+ // and the surface showed an empty failure.
4002
+ text = String(e?.message || e);
4003
+ finish('failed');
4004
+ return;
4005
+ }
4006
+ // The TAIL, not the head: a failing check says why at the end.
4007
+ const keep = (buf) => {
4008
+ text = (text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
4009
+ };
4010
+ child.stdout?.on('data', keep);
4011
+ child.stderr?.on('data', keep);
4012
+ const timer = setTimeout(() => {
4013
+ try {
4014
+ // The GROUP, not the child: killing the shell leaves whatever it
4015
+ // started running, which is the thing actually taking ten minutes.
4016
+ process.kill(-child.pid, 'SIGKILL');
4017
+ } catch {
4018
+ try {
4019
+ child.kill('SIGKILL');
4020
+ } catch {
4021
+ /* already gone */
4022
+ }
4023
+ }
4024
+ text += '\n[flowviant] the check ran past ten minutes and was stopped';
4025
+ // RESOLVE HERE TOO. Waiting for 'close' after a kill is the shape that
4026
+ // hangs: if the group is already gone the event never arrives, and this
4027
+ // promise holds the place's writer lock forever.
4028
+ finish('failed');
4029
+ }, CHECK_TIMEOUT_MS);
4030
+ child.on('error', (e) => {
4031
+ clearTimeout(timer);
4032
+ text += String(e?.message || e);
4033
+ finish('failed');
4034
+ });
4035
+ child.on('close', (code) => {
4036
+ clearTimeout(timer);
4037
+ finish(code === 0 ? 'passed' : 'failed');
4038
+ });
4039
+ });
4040
+ await postCheck({
4041
+ agentId,
4042
+ status: out.status,
4043
+ output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
4044
+ ...(headSha ? { headSha } : {}),
4045
+ });
4046
+ };
4047
+
4048
+ // ── THE MERGE ──────────────────────────────────────────────────────────────
4049
+ //
4050
+ // LEASED, because two `git merge --no-ff` and two pushes over one branch is
4051
+ // the loudest duplicate this system can produce. It reuses `mergeOutward`
4052
+ // verbatim — the same throwaway-worktree merge, the same once-only retry when
4053
+ // two people land at the same moment — because an agent's branch is not
4054
+ // special: it is a branch, and this repo already knows how to land one.
4055
+ //
4056
+ // A SUCCESS CLOSES NOTHING. Done stays OBSERVED: the merge reaches base, the
4057
+ // landed observer's own fetch sees it, and the cards close there.
4058
+ const agentMerges = new Set(); // agent ids in flight on this tick
4059
+
4060
+ const claimAgentMerge = async (agentId) => {
4061
+ try {
4062
+ const res = await fetch(AGENT_MERGE_CLAIM_URL, {
4063
+ method: 'POST',
4064
+ headers: {
4065
+ Authorization: `Bearer ${FLEET_TOKEN}`,
4066
+ 'User-Agent': USER_AGENT,
4067
+ 'Content-Type': 'application/json',
4068
+ },
4069
+ signal: AbortSignal.timeout(15_000),
4070
+ body: JSON.stringify({ agentId, instance: DAEMON_INSTANCE }),
4071
+ });
4072
+ const j = await res.json().catch(() => null);
4073
+ return j?.data?.claimed === true;
4074
+ } catch {
4075
+ return false; // the peer may hold it; doing nothing is the safe answer
4076
+ }
4077
+ };
4078
+
4079
+ const postAgentMerge = async (body) => {
4080
+ try {
4081
+ await fetch(AGENT_MERGE_DONE_URL, {
4082
+ method: 'POST',
4083
+ headers: {
4084
+ Authorization: `Bearer ${FLEET_TOKEN}`,
4085
+ 'User-Agent': USER_AGENT,
4086
+ 'Content-Type': 'application/json',
4087
+ },
4088
+ signal: AbortSignal.timeout(30_000),
4089
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
4090
+ });
4091
+ } catch {
4092
+ /* the lease lapses and the job is re-offered — a merge is idempotent
4093
+ against an already-merged branch, which mergeOutward detects */
4094
+ }
4095
+ };
4096
+
4097
+ /**
4098
+ * A merge COMMIT needs a git identity and the machine may have none. Prefer
4099
+ * the operator's own config; fall back to the daemon's, the same fallback
4100
+ * `checkpointWip` and ship both use, so a bare machine does not fail the fold
4101
+ * with "Please tell me who you are".
4102
+ */
4103
+ const gitMerge = (args, cwd) => {
4104
+ let idEnv = null;
4105
+ try {
4106
+ git(['config', 'user.email'], repoRoot);
4107
+ } catch {
4108
+ idEnv = {
4109
+ GIT_AUTHOR_NAME: 'Flowviant',
4110
+ GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
4111
+ GIT_COMMITTER_NAME: 'Flowviant',
4112
+ GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
4113
+ };
4114
+ }
4115
+ return execFileSync('git', args, {
4116
+ cwd,
4117
+ encoding: 'utf8',
4118
+ stdio: ['ignore', 'pipe', 'pipe'],
4119
+ ...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
4120
+ });
4121
+ };
4122
+
4123
+ const runAgentMerge = async (job) => {
4124
+ const agentId = String(job.agentId);
4125
+ const place = String(job.placeId || '');
4126
+ if (!isSafePathSegment(place)) {
4127
+ await postAgentMerge({ agentId, ok: false, detail: 'the agent has no worktree here' });
4128
+ return;
4129
+ }
4130
+ if (!(await claimAgentMerge(agentId))) return;
4131
+
4132
+ // A WRITER on the place, exactly as a ship is. It folds base in and pushes
4133
+ // with git in that directory, and no CLI can coordinate with something it
4134
+ // does not know exists.
4135
+ await inPlace(place, true, async () => {
4136
+ const wt = join(baseDir, 'sessions', place);
4137
+ if (!existsSync(wt)) {
4138
+ await postAgentMerge({ agentId, ok: false, detail: 'the worktree is gone' });
4139
+ return;
4140
+ }
4141
+ try {
4142
+ git(['fetch', 'origin', '--quiet'], repoRoot);
4143
+ } catch {
4144
+ /* offline — the merge fails honestly below */
4145
+ }
4146
+ // STALE means base moved under this branch while it sat in review. Fold
4147
+ // base IN first so the merge that follows is against what is actually
4148
+ // there; a conflict here is the same conflict the merge would hit, found
4149
+ // one step earlier and in the agent's own directory where it can be
4150
+ // resolved.
4151
+ if (job.stale) {
4152
+ try {
4153
+ gitMerge(['merge', '--no-edit', baseRef()], wt);
4154
+ } catch (e) {
4155
+ await postAgentMerge({
4156
+ agentId,
4157
+ ok: false,
4158
+ detail: envScrub(String(e?.message || e)).slice(0, 2000),
4159
+ });
4160
+ return;
4161
+ }
4162
+ // The branch changed, so the previous check answered about a different
4163
+ // tree. Re-run it before anything merges.
4164
+ await runCheck(agentId, wt);
4165
+ }
4166
+ // `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
4167
+ // a detached HEAD — so the guard below was unreachable and the throw
4168
+ // escaped the claimed merge, leaving it unsettled until its lease lapsed.
4169
+ let branch = '';
4170
+ try {
4171
+ branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim();
4172
+ } catch {
4173
+ branch = '';
4174
+ }
4175
+ if (!branch) {
4176
+ // A detached HEAD names no branch, so there is nothing to merge and
4177
+ // nothing to record. An ambiguity in git, not a rule of ours.
4178
+ await postAgentMerge({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
4179
+ return;
4180
+ }
4181
+ const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
4182
+ const countOut = git(['rev-list', '--count', `${baseRef()}..HEAD`], wt);
4183
+ const count = Number((countOut || '0').trim()) || 0;
4184
+ if (count === 0) {
4185
+ // Already on base — an idempotent re-offer, or an agent that changed
4186
+ // nothing. Reported as a SUCCESS: the branch's work is on base, which
4187
+ // is what the caller is asking about.
4188
+ await postAgentMerge({ agentId, ok: true, sha: tip || undefined });
4189
+ return;
4190
+ }
4191
+ /**
4192
+ * THIS PROJECT MERGES THROUGH A PULL REQUEST.
4193
+ *
4194
+ * PR mode existed for sessions and was simply not honoured for agents:
4195
+ * nothing read the project's merge mode on this path and the direct
4196
+ * merge below ran unconditionally. On the repos PR mode exists FOR —
4197
+ * branch protection refuses a direct push of a merge commit — every
4198
+ * agent approval failed at the push and came back to review carrying
4199
+ * git's refusal, forever.
4200
+ *
4201
+ * PUSH, CREATE, MERGE, in that order, and each step's reasoning is the
4202
+ * session PR job's: push first because GitHub merges the REMOTE tip;
4203
+ * `--fill` titles from the branch's own commits so nothing is invented;
4204
+ * `--merge` and never squash, because the cards' receipts are commit
4205
+ * shas and a squash rewrites them off base, orphaning every receipt and
4206
+ * blinding the landed observer's trailer read. Adopt only an OPEN PR:
4207
+ * gh's branch finder falls back to the most recent merged one, and
4208
+ * adopting a dead PR would report success over work that never moves.
4209
+ */
4210
+ if (job.prMode) {
4211
+ try {
4212
+ execFileSync('gh', ['auth', 'status'], { stdio: ['ignore', 'pipe', 'pipe'] });
4213
+ } catch (e) {
4214
+ await postAgentMerge({
4215
+ agentId,
4216
+ ok: false,
4217
+ detail:
4218
+ e?.code === 'ENOENT'
4219
+ ? 'this project merges through pull requests, and the GitHub CLI (gh) is not installed on this machine'
4220
+ : `this project merges through pull requests, and gh is not signed in here: ${ghFirstLine(e)}`,
4221
+ });
4222
+ return;
4223
+ }
4224
+ const prBase = baseBranchName(baseRef());
4225
+ if (branch === prBase) {
4226
+ await postAgentMerge({
4227
+ agentId,
4228
+ ok: false,
4229
+ detail: `this agent is on the base branch (${prBase}) — there is nothing to open a pull request from`,
4230
+ });
4231
+ return;
4232
+ }
4233
+ try {
4234
+ git(['push', '-u', 'origin', branch], wt);
4235
+ } catch (e) {
4236
+ await postAgentMerge({ agentId, ok: false, detail: ghFirstLine(e) });
4237
+ return;
4238
+ }
4239
+ let prUrl = null;
4240
+ try {
4241
+ const j = JSON.parse(
4242
+ execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
4243
+ cwd: repoRoot,
4244
+ stdio: ['ignore', 'pipe', 'pipe'],
4245
+ }).toString()
4246
+ );
4247
+ if (j?.state === 'OPEN' && typeof j?.url === 'string') prUrl = j.url.trim();
4248
+ } catch {
4249
+ /* no PR for this branch at all — created below */
4250
+ }
4251
+ if (!prUrl) {
4252
+ try {
4253
+ const out = execFileSync(
4254
+ 'gh',
4255
+ // baseBranchName, not baseRef: gh 422s on a remote-tracking name.
4256
+ ['pr', 'create', '--head', branch, '--base', prBase, '--fill'],
4257
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
4258
+ )
4259
+ .toString()
4260
+ .trim();
4261
+ prUrl = out.split('\n').filter(Boolean).pop() ?? null;
4262
+ } catch (e) {
4263
+ await postAgentMerge({ agentId, ok: false, detail: ghFirstLine(e) });
4264
+ return;
4265
+ }
4266
+ }
4267
+ try {
4268
+ execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
4269
+ cwd: repoRoot,
4270
+ stdio: ['ignore', 'pipe', 'pipe'],
4271
+ });
4272
+ } catch (e) {
4273
+ const line = ghFirstLine(e);
4274
+ // Already merged is a SUCCESS: a re-offered job, or somebody merged
4275
+ // it in the browser. The observer closes the cards either way.
4276
+ if (!/already merged/i.test(line)) {
4277
+ await postAgentMerge({
4278
+ agentId,
4279
+ ok: false,
4280
+ detail:
4281
+ prUrl && PR_URL_RE.test(prUrl) ? `${line} — the pull request is at ${prUrl}` : line,
4282
+ });
4283
+ return;
4284
+ }
4285
+ }
4286
+ // THE TIP, not a merge commit: the merge commit was made on GitHub and
4287
+ // this machine has not fetched it. It identifies the state we asked to
4288
+ // be merged, which is what the receipt is for — the cards themselves
4289
+ // close when the landed observer sees the commits arrive on base.
4290
+ await postAgentMerge({ agentId, ok: true, sha: tip });
4291
+ onRepoChanged();
4292
+ landed.observe();
4293
+ return;
4294
+ }
4295
+ try {
4296
+ shipMergeOutward({
4297
+ tip,
4298
+ count,
4299
+ branch,
4300
+ label: job.agentName || place.slice(0, 12),
4301
+ git,
4302
+ gitMerge,
4303
+ repoRoot,
4304
+ tmpDir: join(baseDir, 'ship', place),
4305
+ baseRef,
4306
+ workingTree: wt,
4307
+ warn,
4308
+ });
4309
+ } catch (e) {
4310
+ await postAgentMerge({
4311
+ agentId,
4312
+ ok: false,
4313
+ detail: envScrub(String(e?.message || e)).slice(0, 2000),
4314
+ });
4315
+ return;
4316
+ }
4317
+ await postAgentMerge({ agentId, ok: true, sha: tip });
4318
+ onRepoChanged();
4319
+ landed.observe();
4320
+ });
4321
+ };
4322
+
4323
+ const processAgentMergeJobs = (jobs) => {
4324
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
4325
+ for (const job of jobs.slice(0, 2)) {
4326
+ const id = String(job?.agentId || '');
4327
+ if (!id || agentMerges.has(id)) continue;
4328
+ agentMerges.add(id);
4329
+ void runAgentMerge(job).finally(() => agentMerges.delete(id));
4330
+ }
4331
+ };
4332
+
4333
+ /**
4334
+ * KEEP EACH PERSON'S MANUAL WORKTREE FRESH.
4335
+ *
4336
+ * Every teammate's Workbench tabs share one directory of their own on a
4337
+ * branch of their own — which is not a preference, it is what git allows:
4338
+ * two worktrees cannot have the same branch checked out, so "everyone works
4339
+ * on main" is only true for the machine's OPERATOR, whose place is the
4340
+ * checkout itself. Everyone else needs a branch, and a branch left alone
4341
+ * drifts behind main until the first thing they do in a new tab is a merge
4342
+ * they did not ask for.
4343
+ *
4344
+ * FAST-FORWARD ONLY, and that is the whole safety argument. If their branch
4345
+ * has no commits of its own it simply catches up, which is the ordinary case
4346
+ * and the one worth automating. The moment it HAS diverged, this stops and
4347
+ * leaves it exactly as it is: their commits are theirs, a rebase would
4348
+ * rewrite them under somebody who is not looking, and a merge would put a
4349
+ * commit in their history that they did not make. Their own Claude can fold
4350
+ * base in whenever they ask it to.
4351
+ *
4352
+ * Guarded three ways: a DIRTY tree is left alone (uncommitted work outranks
4353
+ * freshness), a place with a live lock is skipped (a turn is standing in it),
4354
+ * and the whole thing is silent — nothing here reports, warns or blocks.
4355
+ */
4356
+ const freshenManualPlaces = () => {
4357
+ let dir;
4358
+ try {
4359
+ dir = readdirSync(join(baseDir, 'sessions'));
4360
+ } catch {
4361
+ return; // no worktrees yet
4362
+ }
4363
+ for (const place of dir) {
4364
+ // Only a PERSON's manual place. An agent's own worktree (`a-<id>`) is
4365
+ // deliberately not touched: its branch is the reviewable unit, and
4366
+ // moving it under a review would change what somebody is deciding about.
4367
+ if (!place.startsWith('u-') || !isSafePathSegment(place)) continue;
4368
+ if (placeLocks.has(place)) continue;
4369
+ const wt = join(baseDir, 'sessions', place);
4370
+ try {
4371
+ if (git(['status', '--porcelain'], wt).trim() !== '') continue;
4372
+ git(['merge', '--ff-only', baseRef()], wt);
4373
+ } catch {
4374
+ /* diverged, or something else is going on in there. Leave it. */
4375
+ }
4376
+ }
4377
+ };
4378
+
3378
4379
  const workBusy = () =>
3379
4380
  placeLocks.size > 0 ||
4381
+ // A planning turn is a live CLI child of ours, and an auto-update that
4382
+ // SIGTERMs it mid-flight would leave a press claimed, unsettled and
4383
+ // waiting out its lease while somebody watches a spinner.
4384
+ planning.size > 0 ||
4385
+ // An agent turn is real work in a real worktree. Killed mid-flight it
4386
+ // leaves uncommitted edits and a turn the server will eventually expire
4387
+ // into a card nobody can explain.
4388
+ agentTurns.size > 0 ||
4389
+ agentMerges.size > 0 ||
3380
4390
  shipping.size > 0 ||
3381
4391
  workChildren.size > 0 ||
3382
4392
  workAnswering.size > 0 ||
@@ -3400,5 +4410,9 @@ export function createWorkManager({
3400
4410
  reportWorktrees,
3401
4411
  shutdownWork,
3402
4412
  workBusy,
4413
+ processAgentPlanJobs,
4414
+ processAgentTurnJobs,
4415
+ processAgentMergeJobs,
4416
+ freshenManualPlaces,
3403
4417
  };
3404
4418
  }