flowviant 0.78.0 → 0.79.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
@@ -188,6 +188,7 @@ export function createWorkManager({
188
188
  const PR_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-done');
189
189
  const AGENT_PLAN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-claim');
190
190
  const AGENT_PLAN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-done');
191
+ const AGENT_PLAN_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-activity');
191
192
  const AGENT_TURN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-turn-done');
192
193
  const AGENT_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-activity');
193
194
  const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
@@ -574,8 +575,11 @@ export function createWorkManager({
574
575
  // path), but a server bug or compromise sending `../../etc` would
575
576
  // otherwise point a session's measured directory anywhere on the box and
576
577
  // defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
577
- // already reject an unsafe segment; doing it here covers every consumer
578
- // (the preview-claim `placeDir` did not re-check). REPO_PLACE resolves to
578
+ // already reject an unsafe segment; checking at the intake covers the
579
+ // consumers that do not (the preview-claim `placeDir` did not re-check).
580
+ // NOT the only writer: `processWorkTurns` stores a turn job's place too,
581
+ // and keeps the same check — validating one intake and not the other is
582
+ // one deploy away from validating neither. REPO_PLACE resolves to
579
583
  // repoRoot, so it is allowed through despite not being a path segment.
580
584
  if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
581
585
  sessionPlaces.set(sid, place);
@@ -2480,9 +2484,26 @@ export function createWorkManager({
2480
2484
  // run it again while the report is merely undelivered.
2481
2485
  if (pendingWorkReports.has(job.id)) continue;
2482
2486
  workAnswering.add(job.id);
2483
- // Serialized by PLACE: two tabs sharing a worktree take turns in it
2484
- // rather than editing the same files at the same time.
2485
2487
  const place = job.place || job.sessionId;
2488
+ /**
2489
+ * VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
2490
+ * the roster's map — this is the OTHER writer of `sessionPlaces`, in the
2491
+ * same reconcile tick, BEFORE the preview jobs and the sweep read it. An
2492
+ * unchecked value stored here reaches every `placeDir` consumer: the
2493
+ * turn is spawned in it, `burstListeners` measures it, and a share would
2494
+ * publicly tunnel a directory OUTSIDE the checkout — the exact traversal
2495
+ * the preview feature's port attribution exists to prevent. Settled out
2496
+ * loud rather than skipped, because a silently dropped turn strands the
2497
+ * tab for the server's whole expiry window.
2498
+ */
2499
+ if (place !== REPO_PLACE && !isSafePathSegment(place)) {
2500
+ void settleWorkTurn(job.id, {
2501
+ ok: false,
2502
+ answer:
2503
+ 'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
2504
+ }).finally(() => workAnswering.delete(job.id));
2505
+ continue;
2506
+ }
2486
2507
  // Remembered for every other beat — the sweep, ship, the preview
2487
2508
  // re-check — so they all ask the same directory this turn runs in.
2488
2509
  sessionPlaces.set(job.sessionId, place);
@@ -3346,7 +3367,7 @@ export function createWorkManager({
3346
3367
  };
3347
3368
  // The machine may have no git identity, and a merge COMMIT needs
3348
3369
  // one. Prefer the user's own config; fall back to the daemon's (the
3349
- // same fallback checkpointWip uses) so a bare machine doesn't fail
3370
+ // same fallback ship's merge keeps) so a bare machine doesn't fail
3350
3371
  // the fold with "Please tell me who you are".
3351
3372
  let idEnv = null;
3352
3373
  try {
@@ -3617,6 +3638,65 @@ export function createWorkManager({
3617
3638
  }
3618
3639
  };
3619
3640
 
3641
+ /**
3642
+ * WHAT THE PLANNER IS DOING, RELAYED — the agent turn's narration channel,
3643
+ * on a PRESS instead of an agent.
3644
+ *
3645
+ * This turn was already streaming and the daemon was already reading it:
3646
+ * `streamJson` humanizes every read, grep, command and thought and prints
3647
+ * it behind `[plan]` on the operator's terminal. Every one of those lines
3648
+ * was then thrown away, so a Deploy press said "planning" on the board and
3649
+ * nothing else for as long as it took — which on an overnight queue is the
3650
+ * whole night. Forwarding the CLI's own tail costs nothing that is not
3651
+ * already being computed.
3652
+ *
3653
+ * IT IS THE CLI'S WORDS, OR THE MACHINE'S — never a stage, a percentage or
3654
+ * an estimate of how far along a model is. Flowviant relays.
3655
+ *
3656
+ * SCRUBBED AND CAPPED HERE rather than at each call site, so a phase marker
3657
+ * added later cannot skip either. Fire-and-forget with every failure
3658
+ * swallowed: narration is a readout, and it must never fail or delay the
3659
+ * turn it describes. A daemon that never calls this is a machine that has
3660
+ * not narrated, which is the only thing an absent line can mean.
3661
+ */
3662
+ const postAgentPlanActivity = async (planId, line) => {
3663
+ const text = envScrub(String(line ?? '')).slice(0, 400);
3664
+ if (!text) return;
3665
+ try {
3666
+ await fetch(AGENT_PLAN_ACTIVITY_URL, {
3667
+ method: 'POST',
3668
+ headers: {
3669
+ Authorization: `Bearer ${FLEET_TOKEN}`,
3670
+ 'User-Agent': USER_AGENT,
3671
+ 'Content-Type': 'application/json',
3672
+ },
3673
+ signal: AbortSignal.timeout(15_000),
3674
+ body: JSON.stringify({ planId, line: text }),
3675
+ });
3676
+ } catch {
3677
+ /* a readout — losing a line costs nothing */
3678
+ }
3679
+ };
3680
+
3681
+ /**
3682
+ * A WEDGED PLANNING CLI IS STOPPED AT FIFTEEN MINUTES.
3683
+ *
3684
+ * `runTurn` has no timer of its own. A CLI that hangs — a login prompt
3685
+ * nobody is there to answer, a model client stalled on a socket — therefore
3686
+ * runs until something else kills it, holding `planning`, which is what
3687
+ * blocks every auto-update on this machine, and spending whatever the
3688
+ * operator's account is charged for a live session.
3689
+ *
3690
+ * FIFTEEN because the server fails a claimed press at THIRTY (its
3691
+ * `PLAN_JOB_TTL_MS`, measured from `claimedAt`): half of that leaves this
3692
+ * side — the only side that knows the CLI is still running — time to stop it
3693
+ * and have its settle land, instead of the press expiring into a sentence
3694
+ * that blames a machine which never spoke. Nothing legitimate is cut off
3695
+ * either way: this turn reads a card selection and writes nothing, and the
3696
+ * shape of it is minutes.
3697
+ */
3698
+ const PLAN_TURN_TIMEOUT_MS = 15 * 60_000;
3699
+
3620
3700
  const claimAgentPlan = async (id) => {
3621
3701
  try {
3622
3702
  const res = await fetch(AGENT_PLAN_CLAIM_URL, {
@@ -3658,7 +3738,28 @@ export function createWorkManager({
3658
3738
  ['diff', '--name-only', `${baseRef()}...HEAD`],
3659
3739
  ['ls-files', '--others', '--exclude-standard'],
3660
3740
  ]) {
3661
- const r = git(args, wt);
3741
+ /**
3742
+ * `git()` THROWS; it does not return a non-string.
3743
+ *
3744
+ * The guard below was `if (typeof r !== 'string') continue`, which is a
3745
+ * value `execFileSync` can never produce — so every one of these calls
3746
+ * was effectively unguarded. And this runs over the LIVE AGENTS a plan
3747
+ * job carries, which include agents still in `planning`: they have no
3748
+ * worktree yet, so `git diff` in a directory that does not exist exits
3749
+ * non-zero and throws straight out of `runAgentPlan` — after the press
3750
+ * has been claimed and before the try/finally below. The press then sat
3751
+ * `planning` until its expiry, having spent nothing and explained
3752
+ * nothing, on what would be an ordinary second Deploy.
3753
+ *
3754
+ * An agent whose files cannot be read contributes none. That is the
3755
+ * honest answer and it is what the planner should be told.
3756
+ */
3757
+ let r;
3758
+ try {
3759
+ r = git(args, wt);
3760
+ } catch {
3761
+ continue;
3762
+ }
3662
3763
  if (typeof r !== 'string') continue;
3663
3764
  for (const line of r.split('\n')) {
3664
3765
  const f = line.trim();
@@ -3702,11 +3803,49 @@ export function createWorkManager({
3702
3803
  await postAgentPlan({ id, error: 'no CLI on this machine can run a read-only turn' });
3703
3804
  return;
3704
3805
  }
3806
+
3807
+ /**
3808
+ * ONE LINE AT A TIME, AT MOST ONE EVERY TWO SECONDS — the throttle the
3809
+ * agent turn's narration keeps, for the same reason: a turn emits hundreds
3810
+ * of lines and only the latest is ever rendered. Held per RUN rather than
3811
+ * in a map keyed by press, because one press is one turn and there is
3812
+ * nothing for the clock to outlive.
3813
+ */
3814
+ let lastPlanBeat = 0;
3815
+ const narrate = (line) => {
3816
+ const now = Date.now();
3817
+ if (now - lastPlanBeat < 2_000) return;
3818
+ lastPlanBeat = now;
3819
+ void postAgentPlanActivity(id, line);
3820
+ };
3821
+ /**
3822
+ * THE MACHINE'S OWN VOICE, for the moments only this side can see — the
3823
+ * CLI process actually starting, and this press waiting its turn for the
3824
+ * checkout. Both are FACTS measured here, not a reading of the model's
3825
+ * progress, and both are invisible from a browser: a press held behind a
3826
+ * ship's writer lock looks exactly like a press whose CLI is thinking.
3827
+ *
3828
+ * Never dropped by the throttle above — these are moments, not a stream —
3829
+ * and they stamp its clock so the next model line does not overwrite one
3830
+ * the instant it lands.
3831
+ */
3832
+ const say = (line) => {
3833
+ lastPlanBeat = Date.now();
3834
+ void postAgentPlanActivity(id, line);
3835
+ };
3705
3836
  const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map((a) => ({
3706
3837
  id: String(a?.id ?? ''),
3707
3838
  name: String(a?.name ?? ''),
3708
3839
  status: String(a?.status ?? ''),
3709
- changedFiles: agentChangedFiles(a?.placeId),
3840
+ // Belt: `agentChangedFiles` is defensive internally, but it resolves a
3841
+ // place first and this whole block sits outside the try below.
3842
+ changedFiles: (() => {
3843
+ try {
3844
+ return agentChangedFiles(a?.placeId);
3845
+ } catch {
3846
+ return [];
3847
+ }
3848
+ })(),
3710
3849
  }));
3711
3850
 
3712
3851
  let out = '';
@@ -3714,9 +3853,29 @@ export function createWorkManager({
3714
3853
  * a leaked entry keeps the daemon permanently "busy" — which blocks every
3715
3854
  * auto-update from that moment on, silently, until a restart. */
3716
3855
  let planChild = null;
3856
+ let planTimer = null;
3857
+ /** The cap fired: the CLI was still running when this machine stopped it. */
3858
+ let wedged = false;
3859
+ // Said BEFORE the lock is asked for, because a reader only waits when a
3860
+ // writer holds the checkout or is queued ahead of it — which is exactly
3861
+ // this condition, read one line before we join the queue.
3862
+ const busy = placeLocks.get(REPO_PLACE);
3863
+ if (busy && (busy.writing || busy.waiters.some((w) => w.write)))
3864
+ say('waiting for the checkout — a ship or another turn on this machine is holding it');
3717
3865
  try {
3718
3866
  await inPlace(REPO_PLACE, false, async () => {
3719
- out = await runTurn({
3867
+ /**
3868
+ * THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close`
3869
+ * after the kill — the shape the project check's timeout already
3870
+ * keeps. A SIGKILLed process whose stdio a grandchild still holds can
3871
+ * be slow to emit `close`, or never emit it, and this promise is what
3872
+ * holds the claimed press open.
3873
+ */
3874
+ let stopWaiting = () => {};
3875
+ const capped = new Promise((r) => {
3876
+ stopWaiting = r;
3877
+ });
3878
+ const turn = runTurn({
3720
3879
  prompt: AGENT_PLAN_KICKOFF({
3721
3880
  tasks,
3722
3881
  liveAgents,
@@ -3731,19 +3890,61 @@ export function createWorkManager({
3731
3890
  streamJson: true,
3732
3891
  answerFromResult: true,
3733
3892
  label: c.cyan('[plan]'),
3893
+ // The CLI's own tail, forwarded to the press. Same rule as an agent
3894
+ // turn's: throttled, overwritten rather than appended, and never
3895
+ // awaited by the turn.
3896
+ onActivity: (a) => {
3897
+ if (a?.label) narrate(String(a.label));
3898
+ },
3734
3899
  onSpawn: (ch) => {
3735
3900
  planChild = ch;
3736
3901
  workChildren.set(ch, null);
3902
+ say(`${RUNTIMES[rt]?.label ?? rt} started on this machine`);
3903
+ /**
3904
+ * ARMED AT THE SPAWN, not at the claim: time spent waiting for the
3905
+ * checkout is not a wedged CLI, and a press that never got to run
3906
+ * is what the server's own clock is for.
3907
+ *
3908
+ * The CHILD, never its group — the rule teardown keeps. A
3909
+ * read-only planning turn starts no dev server, so there is
3910
+ * nothing behind it worth signalling and everything to lose by
3911
+ * signalling somebody else's.
3912
+ */
3913
+ planTimer = setTimeout(() => {
3914
+ wedged = true;
3915
+ try {
3916
+ ch.kill('SIGKILL');
3917
+ } catch {
3918
+ /* already gone */
3919
+ }
3920
+ stopWaiting('');
3921
+ }, PLAN_TURN_TIMEOUT_MS);
3922
+ planTimer.unref?.();
3737
3923
  },
3738
3924
  });
3925
+ out = await Promise.race([turn, capped]);
3739
3926
  });
3740
3927
  } catch (e) {
3741
3928
  await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500) });
3742
3929
  return;
3743
3930
  } finally {
3931
+ if (planTimer) clearTimeout(planTimer);
3744
3932
  if (planChild) workChildren.delete(planChild);
3745
3933
  }
3746
3934
 
3935
+ if (wedged) {
3936
+ // NEVER LEAVE A CLAIMED PRESS UNREPORTED — the belt the merge lane wears
3937
+ // beneath this one. The machine's own words, because the machine is the
3938
+ // only side that knows: the server would have expired this press in
3939
+ // another fifteen minutes with a sentence blaming a daemon that had in
3940
+ // fact answered.
3941
+ await postAgentPlan({
3942
+ id,
3943
+ error: 'the planning turn ran past fifteen minutes on this machine and was stopped',
3944
+ });
3945
+ return;
3946
+ }
3947
+
3747
3948
  const proposal = parseProposal(out);
3748
3949
  if (!proposal) {
3749
3950
  await postAgentPlan({
@@ -3787,8 +3988,13 @@ export function createWorkManager({
3787
3988
  // UNLEASED, unlike a plan or a kill. The server hands out at most one turn
3788
3989
  // per agent per poll and its settle is conditional on the row still being
3789
3990
  // pending, so a second daemon cannot advance the queue twice. What it could
3790
- // do is run a CLI twice in one worktree, which is what this in-flight set and
3791
- // the place lock prevent the same discipline `processWorkTurns` keeps.
3991
+ // do is run a CLI twice in one worktree. The in-flight set below cannot
3992
+ // prevent that alone: it is keyed by TURN id, so it never sees the
3993
+ // DIFFERENT turn the server's TTL-skip hands out while an expired turn's
3994
+ // CLI is still running — and a reader lock would run the two side by side.
3995
+ // So an agent turn takes its place's lock as a WRITER: an agent is ONE
3996
+ // process by definition, and its `a-<id>` place is its own — no tab ever
3997
+ // stands there, so the tabs' turns-run-concurrently law is untouched.
3792
3998
  const agentTurns = new Set(); // turn ids in flight on this tick
3793
3999
  /**
3794
4000
  * The CLI a live agent turn is running in, by PLACE.
@@ -3805,14 +4011,29 @@ export function createWorkManager({
3805
4011
  * emptied, so run the project's own check in the worktree we are already
3806
4012
  * standing in. A job lane for that would need a claim, a floor and a settle
3807
4013
  * to say something this reply already can. */
3808
- /** Turn ids whose work is DONE but whose report has not landed. The same
3809
- * skip-guard `pendingWorkReports` is for a tab, and for the same reason: a
3810
- * settle that fails to POST must not re-run the turn that is a second CLI,
3811
- * a second set of commits, and the operator's quota spent again. */
3812
- const agentReported = new Set();
4014
+ /**
4015
+ * Turns whose work is DONE but whose settle has not landed, keyed to the
4016
+ * finished BODY. The delivery half of what `pendingWorkReports` is for a
4017
+ * tab: a settle that fails to POST must not re-run the turn that is a
4018
+ * second CLI, a second set of commits, and the operator's quota spent again
4019
+ * — but a guard that only SKIPPED left the other half undone. One failed
4020
+ * POST parked the agent for the server's whole six-hour expiry, holding a
4021
+ * cap slot the entire time, and then expired into "nobody ran it" — a false
4022
+ * sentence about a turn this machine finished. The server's settle is
4023
+ * idempotent (conditional on the row still being pending), so a re-offer of
4024
+ * a held turn re-POSTs the stored body instead: safe, and it lands the
4025
+ * moment the network heals rather than six hours later.
4026
+ *
4027
+ * BOUNDED by the roster itself: a held body's clock is refreshed while the
4028
+ * server keeps offering its turn, and once offering stops — settled by the
4029
+ * re-POST, or expired server-side — the grace below is all that keeps it.
4030
+ */
4031
+ const agentReported = new Map(); // turnId -> { body, at }
4032
+ const AGENT_REPORT_GRACE_MS = 30 * 60_000;
3813
4033
 
3814
4034
  const postAgentTurn = async (body) => {
3815
- agentReported.add(String(body.turnId));
4035
+ const turnId = String(body.turnId);
4036
+ agentReported.set(turnId, { body, at: Date.now() });
3816
4037
  try {
3817
4038
  const res = await fetch(AGENT_TURN_DONE_URL, {
3818
4039
  method: 'POST',
@@ -3825,14 +4046,19 @@ export function createWorkManager({
3825
4046
  body: JSON.stringify(body),
3826
4047
  });
3827
4048
  const j = await res.json().catch(() => null);
3828
- // Landed. Forgetting it keeps the guard from growing without bound; a
3829
- // re-offer of a settled turn is refused server-side anyway.
3830
- if (res.ok) agentReported.delete(String(body.turnId));
4049
+ // Landed, or REFUSED: a 4xx is the server saying this settle will never
4050
+ // be accepted (expired, already settled, unknown turn), and re-POSTing a
4051
+ // refusal forever is the wedge wearing a retry's clothes. 408/429 stay
4052
+ // retryable, the same split `postSettle` makes for a tab.
4053
+ if (
4054
+ res.ok ||
4055
+ (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
4056
+ )
4057
+ agentReported.delete(turnId);
3831
4058
  return j?.data ?? null;
3832
4059
  } catch {
3833
- // Unsettled, and the id STAYS in the guard: the server expires the turn
3834
- // and the agent lands in Stuck saying nobody ran it, which is far better
3835
- // than running it again for six hours.
4060
+ // Unsettled a network error, so the body STAYS held and the next
4061
+ // re-offer retries the POST rather than the CLI.
3836
4062
  return null;
3837
4063
  }
3838
4064
  };
@@ -3953,7 +4179,10 @@ export function createWorkManager({
3953
4179
  return;
3954
4180
  }
3955
4181
 
3956
- await inPlace(place, false, async () => {
4182
+ // A WRITER on the agent's own place see the lane header. Only `a-<id>`
4183
+ // places: those are agents' by construction, and anything else here would
4184
+ // be a tab's directory, where a turn is a reader by the product's own law.
4185
+ await inPlace(place, place.startsWith('a-'), async () => {
3957
4186
  const dir = placeWtFor(place);
3958
4187
  if (!dir) {
3959
4188
  // No worktree and none could be cut. `nothing` rather than an invented
@@ -4074,6 +4303,20 @@ export function createWorkManager({
4074
4303
  /* a missing marker only costs one un-resumed turn */
4075
4304
  }
4076
4305
  }
4306
+ /**
4307
+ * AN ACTION THAT CHANGES WHAT THE MACHINE WOULD MEASURE MUST CAUSE A
4308
+ * NEW MEASUREMENT — the rule every session settle keeps, and the one
4309
+ * lane that did not. An agent's branch diff, head sha and trailered
4310
+ * commits only refreshed on the ≤60s sweep, so review opened right
4311
+ * after a turn described the branch as it was BEFORE the work.
4312
+ *
4313
+ * Here rather than after the settle POST because the CLI has exited,
4314
+ * so the tree is final — and because a queue that just emptied runs
4315
+ * the project's check next, which may hold this function for ten
4316
+ * minutes. Fire-and-forget on an endpoint that already exists, so no
4317
+ * floor: it must never delay the settle behind it.
4318
+ */
4319
+ void reportSessionWorktree(place).catch(() => {});
4077
4320
  }
4078
4321
 
4079
4322
  const commits = commitsBetween(wt, before);
@@ -4148,13 +4391,46 @@ export function createWorkManager({
4148
4391
  };
4149
4392
 
4150
4393
  const processAgentTurnJobs = (jobs) => {
4151
- if (!Array.isArray(jobs) || jobs.length === 0) return;
4152
- for (const job of jobs.slice(0, 4)) {
4394
+ const list = Array.isArray(jobs) ? jobs : [];
4395
+ if (agentReported.size) {
4396
+ // The roster is the held bodies' clock: an offered turn is still pending
4397
+ // server-side and worth retrying; one the roster stopped naming was
4398
+ // settled or expired, and holding its body past a generous grace would
4399
+ // grow this map for the life of the process. The server's own expiry is
4400
+ // the true bound — the grace only covers its POST racing a final offer.
4401
+ const offered = new Set(list.map((j) => String(j?.id || '')));
4402
+ const now = Date.now();
4403
+ for (const [id, held] of agentReported) {
4404
+ if (offered.has(id)) held.at = now;
4405
+ else if (now - held.at > AGENT_REPORT_GRACE_MS) agentReported.delete(id);
4406
+ }
4407
+ }
4408
+ for (const job of list.slice(0, 4)) {
4153
4409
  const id = String(job?.id || '');
4154
4410
  if (!id || agentTurns.has(id)) continue;
4155
- // Already RAN here; only the report is outstanding. Re-running it would
4156
- // spend the operator's quota again and write a second set of commits.
4157
- if (agentReported.has(id)) continue;
4411
+ const held = agentReported.get(id);
4412
+ if (held) {
4413
+ // Already RAN here; only the settle is outstanding. Re-POST the held
4414
+ // body — never the CLI, which would spend the operator's quota again
4415
+ // and write a second set of commits. The reply can still carry the one
4416
+ // instruction a settle can (`review: true`, the queue just emptied),
4417
+ // so the project's check runs from here too, under the same writer
4418
+ // lock the turn itself would have held.
4419
+ agentTurns.add(id);
4420
+ void (async () => {
4421
+ const reply = await postAgentTurn(held.body);
4422
+ const place = String(job.placeId || '');
4423
+ const wt = typeof held.body.worktree === 'string' ? held.body.worktree : null;
4424
+ if (reply?.review === true && isSafePathSegment(place) && wt && existsSync(wt)) {
4425
+ await inPlace(place, place.startsWith('a-'), () =>
4426
+ runCheck(String(job.agentId || ''), wt)
4427
+ );
4428
+ }
4429
+ })()
4430
+ .catch(() => {})
4431
+ .finally(() => agentTurns.delete(id));
4432
+ continue;
4433
+ }
4158
4434
  if (!job.agentId || !job.placeId) continue;
4159
4435
  agentTurns.add(id);
4160
4436
  void runAgentTurn(job).finally(() => agentTurns.delete(id));
@@ -4382,8 +4658,8 @@ export function createWorkManager({
4382
4658
  /**
4383
4659
  * A merge COMMIT needs a git identity and the machine may have none. Prefer
4384
4660
  * the operator's own config; fall back to the daemon's, the same fallback
4385
- * `checkpointWip` and ship both use, so a bare machine does not fail the fold
4386
- * with "Please tell me who you are".
4661
+ * ship's merge keeps, so a bare machine does not fail the fold with
4662
+ * "Please tell me who you are".
4387
4663
  */
4388
4664
  const gitMerge = (args, cwd) => {
4389
4665
  let idEnv = null;
@@ -4409,18 +4685,37 @@ export function createWorkManager({
4409
4685
  const agentId = String(job.agentId);
4410
4686
  const place = String(job.placeId || '');
4411
4687
  if (!isSafePathSegment(place)) {
4688
+ // Before the claim, and before `report` exists — this one is not covered
4689
+ // by the belt below because there is nothing yet to be un-reported.
4412
4690
  await postAgentMerge({ agentId, ok: false, detail: 'the agent has no worktree here' });
4413
4691
  return;
4414
4692
  }
4415
4693
  if (!(await claimAgentMerge(agentId))) return;
4416
4694
 
4695
+ /**
4696
+ * NO EXIT PATH MAY LEAVE A CLAIMED MERGE UNREPORTED — the same belt the
4697
+ * ship path carries, and this function did not.
4698
+ *
4699
+ * Every branch below reports, but an UNEXPECTED throw reports nothing:
4700
+ * several `git()` calls in here are bare, and `git()` is `execFileSync`,
4701
+ * which throws on any non-zero exit. The agent then sits in `merging`,
4702
+ * which refuses Stop and refuses an answer, until an expiry fires — with
4703
+ * nothing anywhere saying what went wrong.
4704
+ */
4705
+ let reported = false;
4706
+ const report = async (body) => {
4707
+ reported = true;
4708
+ await postAgentMerge(body);
4709
+ };
4710
+
4417
4711
  // A WRITER on the place, exactly as a ship is. It folds base in and pushes
4418
4712
  // with git in that directory, and no CLI can coordinate with something it
4419
4713
  // does not know exists.
4420
- await inPlace(place, true, async () => {
4714
+ try {
4715
+ await inPlace(place, true, async () => {
4421
4716
  const wt = join(baseDir, 'sessions', place);
4422
4717
  if (!existsSync(wt)) {
4423
- await postAgentMerge({ agentId, ok: false, detail: 'the worktree is gone' });
4718
+ await report({ agentId, ok: false, detail: 'the worktree is gone' });
4424
4719
  return;
4425
4720
  }
4426
4721
  try {
@@ -4437,7 +4732,7 @@ export function createWorkManager({
4437
4732
  try {
4438
4733
  gitMerge(['merge', '--no-edit', baseRef()], wt);
4439
4734
  } catch (e) {
4440
- await postAgentMerge({
4735
+ await report({
4441
4736
  agentId,
4442
4737
  ok: false,
4443
4738
  detail: envScrub(String(e?.message || e)).slice(0, 2000),
@@ -4460,7 +4755,7 @@ export function createWorkManager({
4460
4755
  if (!branch) {
4461
4756
  // A detached HEAD names no branch, so there is nothing to merge and
4462
4757
  // nothing to record. An ambiguity in git, not a rule of ours.
4463
- await postAgentMerge({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
4758
+ await report({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
4464
4759
  return;
4465
4760
  }
4466
4761
  const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
@@ -4470,7 +4765,7 @@ export function createWorkManager({
4470
4765
  // Already on base — an idempotent re-offer, or an agent that changed
4471
4766
  // nothing. Reported as a SUCCESS: the branch's work is on base, which
4472
4767
  // is what the caller is asking about.
4473
- await postAgentMerge({ agentId, ok: true, sha: tip || undefined });
4768
+ await report({ agentId, ok: true, sha: tip || undefined });
4474
4769
  return;
4475
4770
  }
4476
4771
  /**
@@ -4504,7 +4799,7 @@ export function createWorkManager({
4504
4799
  timeout: 20_000,
4505
4800
  });
4506
4801
  } catch (e) {
4507
- await postAgentMerge({
4802
+ await report({
4508
4803
  agentId,
4509
4804
  ok: false,
4510
4805
  detail:
@@ -4516,7 +4811,7 @@ export function createWorkManager({
4516
4811
  }
4517
4812
  const prBase = baseBranchName(baseRef());
4518
4813
  if (branch === prBase) {
4519
- await postAgentMerge({
4814
+ await report({
4520
4815
  agentId,
4521
4816
  ok: false,
4522
4817
  detail: `this agent is on the base branch (${prBase}) — there is nothing to open a pull request from`,
@@ -4531,19 +4826,34 @@ export function createWorkManager({
4531
4826
  // token in its userinfo — so this one line is the only place on the
4532
4827
  // agent merge path that can leak a credential into a stored,
4533
4828
  // team-visible `mergeError`.
4534
- await postAgentMerge({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
4829
+ await report({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
4535
4830
  return;
4536
4831
  }
4537
4832
  let prUrl = null;
4538
4833
  try {
4539
4834
  const j = JSON.parse(
4540
- execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
4835
+ execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state,baseRefName'], {
4541
4836
  cwd: repoRoot,
4542
4837
  stdio: ['ignore', 'pipe', 'pipe'],
4543
4838
  timeout: 30_000,
4544
4839
  }).toString()
4545
4840
  );
4546
- if (j?.state === 'OPEN' && typeof j?.url === 'string') prUrl = j.url.trim();
4841
+ if (j?.state === 'OPEN' && typeof j?.url === 'string') {
4842
+ // Adoptable only when it points at the project's own base. A PR
4843
+ // somebody opened by hand against another branch would otherwise
4844
+ // be merged INTO that branch, and the ok settle would claim work
4845
+ // reached base that landed somewhere else entirely. Refused only
4846
+ // on a MEASURED mismatch — an absent field adopts as before.
4847
+ if (typeof j?.baseRefName === 'string' && j.baseRefName !== prBase) {
4848
+ await report({
4849
+ agentId,
4850
+ ok: false,
4851
+ detail: `the open pull request for ${branch} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
4852
+ });
4853
+ return;
4854
+ }
4855
+ prUrl = j.url.trim();
4856
+ }
4547
4857
  } catch {
4548
4858
  /* no PR for this branch at all — created below */
4549
4859
  }
@@ -4559,7 +4869,7 @@ export function createWorkManager({
4559
4869
  .trim();
4560
4870
  prUrl = out.split('\n').filter(Boolean).pop() ?? null;
4561
4871
  } catch (e) {
4562
- await postAgentMerge({ agentId, ok: false, detail: ghFirstLine(e) });
4872
+ await report({ agentId, ok: false, detail: ghFirstLine(e) });
4563
4873
  return;
4564
4874
  }
4565
4875
  }
@@ -4574,7 +4884,7 @@ export function createWorkManager({
4574
4884
  // Already merged is a SUCCESS: a re-offered job, or somebody merged
4575
4885
  // it in the browser. The observer closes the cards either way.
4576
4886
  if (!/already merged/i.test(line)) {
4577
- await postAgentMerge({
4887
+ await report({
4578
4888
  agentId,
4579
4889
  ok: false,
4580
4890
  detail:
@@ -4583,11 +4893,46 @@ export function createWorkManager({
4583
4893
  return;
4584
4894
  }
4585
4895
  }
4586
- // THE TIP, not a merge commit: the merge commit was made on GitHub and
4587
- // this machine has not fetched it. It identifies the state we asked to
4588
- // be merged, which is what the receipt is for the cards themselves
4589
- // close when the landed observer sees the commits arrive on base.
4590
- await postAgentMerge({ agentId, ok: true, sha: tip });
4896
+ // VERIFY before reporting ok: modern gh exits 0 on an already-MERGED
4897
+ // PR, and on a repo with a merge queue or auto-merge it exits 0 after
4898
+ // ENQUEUEING — in both, "merged" is a claim about the future. The tip
4899
+ // being an ancestor of base is the fact `ok` asserts, and the server
4900
+ // closes every delivered card on this sha the moment it hears it — so
4901
+ // measure it, with one short retry for the fetch racing GitHub's
4902
+ // merge commit. The same guard the session PR path carries.
4903
+ const tipOnBase = () => {
4904
+ try {
4905
+ git(['merge-base', '--is-ancestor', tip, baseRef()], repoRoot);
4906
+ return true;
4907
+ } catch {
4908
+ return false;
4909
+ }
4910
+ };
4911
+ let landedOnBase = false;
4912
+ for (let attempt = 0; attempt < 2 && !landedOnBase; attempt++) {
4913
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
4914
+ try {
4915
+ git(['fetch', 'origin', '--quiet'], repoRoot);
4916
+ } catch {
4917
+ /* offline — the check below answers from what we have */
4918
+ }
4919
+ landedOnBase = tipOnBase();
4920
+ }
4921
+ if (!landedOnBase) {
4922
+ await report({
4923
+ agentId,
4924
+ ok: false,
4925
+ detail:
4926
+ "GitHub accepted the merge, but this branch's tip is not on the base branch — a merge queue may still be running it, or the PR that merged was an older one. Approve again once it lands." +
4927
+ (prUrl && PR_URL_RE.test(prUrl) ? ` The pull request is at ${prUrl}.` : ''),
4928
+ });
4929
+ return;
4930
+ }
4931
+ // THE TIP, not the merge commit GitHub made: the tip identifies the
4932
+ // state we asked to be merged, which is what the receipt is for — the
4933
+ // cards themselves close when the landed observer sees the commits
4934
+ // arrive on base.
4935
+ await report({ agentId, ok: true, sha: tip });
4591
4936
  onRepoChanged();
4592
4937
  landed.observe();
4593
4938
  return;
@@ -4607,17 +4952,29 @@ export function createWorkManager({
4607
4952
  warn,
4608
4953
  });
4609
4954
  } catch (e) {
4610
- await postAgentMerge({
4955
+ await report({
4611
4956
  agentId,
4612
4957
  ok: false,
4613
4958
  detail: envScrub(String(e?.message || e)).slice(0, 2000),
4614
4959
  });
4615
4960
  return;
4616
4961
  }
4617
- await postAgentMerge({ agentId, ok: true, sha: tip });
4962
+ await report({ agentId, ok: true, sha: tip });
4618
4963
  onRepoChanged();
4619
4964
  landed.observe();
4620
- });
4965
+ });
4966
+ } finally {
4967
+ if (!reported) {
4968
+ // Belt over braces. A merge this daemon claimed and cannot account for
4969
+ // is a FAILURE, said out loud, so the agent goes back to Review with a
4970
+ // reason instead of waiting out an expiry that blames nobody.
4971
+ await postAgentMerge({
4972
+ agentId,
4973
+ ok: false,
4974
+ detail: 'the merge did not complete — check the daemon log',
4975
+ }).catch(() => {});
4976
+ }
4977
+ }
4621
4978
  };
4622
4979
 
4623
4980
  const processAgentMergeJobs = (jobs) => {
@@ -4687,6 +5044,10 @@ export function createWorkManager({
4687
5044
  // into a card nobody can explain.
4688
5045
  agentTurns.size > 0 ||
4689
5046
  agentMerges.size > 0 ||
5047
+ // A finished agent turn whose settle has not landed lives only in this
5048
+ // process; a restart here is the six-hour park the held body exists to
5049
+ // prevent. Same reason the tab's report queues are below.
5050
+ agentReported.size > 0 ||
4690
5051
  shipping.size > 0 ||
4691
5052
  workChildren.size > 0 ||
4692
5053
  workAnswering.size > 0 ||