flowviant 0.78.0 → 0.78.1

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.
@@ -19,11 +19,15 @@ import { baseBranchName } from './git.mjs';
19
19
  *
20
20
  * `git branch -d` IS THE GUARD, deliberately, rather than a stack of checks
21
21
  * of our own. It refuses an UNMERGED branch and it refuses one CHECKED OUT in
22
- * any worktree — which are two of the three conditions, enforced by the tool
23
- * that owns the truth instead of by our reading of it. Never `-D`: if git
24
- * objects, git is right and we stop. The third condition is ours and is the
25
- * name: only `session/<id>` exactly, so a branch the agent cut is never in
26
- * scope no matter what it was merged into.
22
+ * any worktree — enforced by the tool that owns the truth instead of by our
23
+ * reading of it. Never `-D`: if git objects, git is right and we stop. Two
24
+ * conditions are ours on top of it. The NAME: only `session/<id>` exactly, so
25
+ * a branch the agent cut is never in scope no matter what it was merged into.
26
+ * And ANCESTRY OF BASE: `-d` with no upstream judges "merged" against HEAD,
27
+ * so an operator parked on an experimental branch that merged the session
28
+ * work would get the delete while the narration below claimed "merged into
29
+ * <base>" for commits base has never seen — the claim is checked against the
30
+ * thing it names before git is asked at all.
27
31
  *
28
32
  * ORDERING IS LOAD-BEARING. This must not run while a ship report is still
29
33
  * undelivered. Ship's idempotency path recovers from a lost report by asking
@@ -45,6 +49,13 @@ export function sweepMergedBranch(sessionId, { git, repoRoot, baseRef, note, isR
45
49
  } catch {
46
50
  return false; // already gone, or never existed
47
51
  }
52
+ try {
53
+ git(['merge-base', '--is-ancestor', `refs/heads/${name}`, baseRef], repoRoot);
54
+ } catch {
55
+ // Not on base — merged into something, perhaps, but not into the thing the
56
+ // narration names, and not ours to retire on HEAD's say-so.
57
+ return false;
58
+ }
48
59
  try {
49
60
  git(['branch', '-d', name], repoRoot);
50
61
  } catch {
package/bin/lib/work.mjs CHANGED
@@ -574,8 +574,11 @@ export function createWorkManager({
574
574
  // path), but a server bug or compromise sending `../../etc` would
575
575
  // otherwise point a session's measured directory anywhere on the box and
576
576
  // 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
577
+ // already reject an unsafe segment; checking at the intake covers the
578
+ // consumers that do not (the preview-claim `placeDir` did not re-check).
579
+ // NOT the only writer: `processWorkTurns` stores a turn job's place too,
580
+ // and keeps the same check — validating one intake and not the other is
581
+ // one deploy away from validating neither. REPO_PLACE resolves to
579
582
  // repoRoot, so it is allowed through despite not being a path segment.
580
583
  if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
581
584
  sessionPlaces.set(sid, place);
@@ -2480,9 +2483,26 @@ export function createWorkManager({
2480
2483
  // run it again while the report is merely undelivered.
2481
2484
  if (pendingWorkReports.has(job.id)) continue;
2482
2485
  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
2486
  const place = job.place || job.sessionId;
2487
+ /**
2488
+ * VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
2489
+ * the roster's map — this is the OTHER writer of `sessionPlaces`, in the
2490
+ * same reconcile tick, BEFORE the preview jobs and the sweep read it. An
2491
+ * unchecked value stored here reaches every `placeDir` consumer: the
2492
+ * turn is spawned in it, `burstListeners` measures it, and a share would
2493
+ * publicly tunnel a directory OUTSIDE the checkout — the exact traversal
2494
+ * the preview feature's port attribution exists to prevent. Settled out
2495
+ * loud rather than skipped, because a silently dropped turn strands the
2496
+ * tab for the server's whole expiry window.
2497
+ */
2498
+ if (place !== REPO_PLACE && !isSafePathSegment(place)) {
2499
+ void settleWorkTurn(job.id, {
2500
+ ok: false,
2501
+ answer:
2502
+ 'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
2503
+ }).finally(() => workAnswering.delete(job.id));
2504
+ continue;
2505
+ }
2486
2506
  // Remembered for every other beat — the sweep, ship, the preview
2487
2507
  // re-check — so they all ask the same directory this turn runs in.
2488
2508
  sessionPlaces.set(job.sessionId, place);
@@ -3346,7 +3366,7 @@ export function createWorkManager({
3346
3366
  };
3347
3367
  // The machine may have no git identity, and a merge COMMIT needs
3348
3368
  // 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
3369
+ // same fallback ship's merge keeps) so a bare machine doesn't fail
3350
3370
  // the fold with "Please tell me who you are".
3351
3371
  let idEnv = null;
3352
3372
  try {
@@ -3658,7 +3678,28 @@ export function createWorkManager({
3658
3678
  ['diff', '--name-only', `${baseRef()}...HEAD`],
3659
3679
  ['ls-files', '--others', '--exclude-standard'],
3660
3680
  ]) {
3661
- const r = git(args, wt);
3681
+ /**
3682
+ * `git()` THROWS; it does not return a non-string.
3683
+ *
3684
+ * The guard below was `if (typeof r !== 'string') continue`, which is a
3685
+ * value `execFileSync` can never produce — so every one of these calls
3686
+ * was effectively unguarded. And this runs over the LIVE AGENTS a plan
3687
+ * job carries, which include agents still in `planning`: they have no
3688
+ * worktree yet, so `git diff` in a directory that does not exist exits
3689
+ * non-zero and throws straight out of `runAgentPlan` — after the press
3690
+ * has been claimed and before the try/finally below. The press then sat
3691
+ * `planning` until its expiry, having spent nothing and explained
3692
+ * nothing, on what would be an ordinary second Deploy.
3693
+ *
3694
+ * An agent whose files cannot be read contributes none. That is the
3695
+ * honest answer and it is what the planner should be told.
3696
+ */
3697
+ let r;
3698
+ try {
3699
+ r = git(args, wt);
3700
+ } catch {
3701
+ continue;
3702
+ }
3662
3703
  if (typeof r !== 'string') continue;
3663
3704
  for (const line of r.split('\n')) {
3664
3705
  const f = line.trim();
@@ -3706,7 +3747,15 @@ export function createWorkManager({
3706
3747
  id: String(a?.id ?? ''),
3707
3748
  name: String(a?.name ?? ''),
3708
3749
  status: String(a?.status ?? ''),
3709
- changedFiles: agentChangedFiles(a?.placeId),
3750
+ // Belt: `agentChangedFiles` is defensive internally, but it resolves a
3751
+ // place first and this whole block sits outside the try below.
3752
+ changedFiles: (() => {
3753
+ try {
3754
+ return agentChangedFiles(a?.placeId);
3755
+ } catch {
3756
+ return [];
3757
+ }
3758
+ })(),
3710
3759
  }));
3711
3760
 
3712
3761
  let out = '';
@@ -3787,8 +3836,13 @@ export function createWorkManager({
3787
3836
  // UNLEASED, unlike a plan or a kill. The server hands out at most one turn
3788
3837
  // per agent per poll and its settle is conditional on the row still being
3789
3838
  // 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.
3839
+ // do is run a CLI twice in one worktree. The in-flight set below cannot
3840
+ // prevent that alone: it is keyed by TURN id, so it never sees the
3841
+ // DIFFERENT turn the server's TTL-skip hands out while an expired turn's
3842
+ // CLI is still running — and a reader lock would run the two side by side.
3843
+ // So an agent turn takes its place's lock as a WRITER: an agent is ONE
3844
+ // process by definition, and its `a-<id>` place is its own — no tab ever
3845
+ // stands there, so the tabs' turns-run-concurrently law is untouched.
3792
3846
  const agentTurns = new Set(); // turn ids in flight on this tick
3793
3847
  /**
3794
3848
  * The CLI a live agent turn is running in, by PLACE.
@@ -3805,14 +3859,29 @@ export function createWorkManager({
3805
3859
  * emptied, so run the project's own check in the worktree we are already
3806
3860
  * standing in. A job lane for that would need a claim, a floor and a settle
3807
3861
  * 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();
3862
+ /**
3863
+ * Turns whose work is DONE but whose settle has not landed, keyed to the
3864
+ * finished BODY. The delivery half of what `pendingWorkReports` is for a
3865
+ * tab: a settle that fails to POST must not re-run the turn that is a
3866
+ * second CLI, a second set of commits, and the operator's quota spent again
3867
+ * — but a guard that only SKIPPED left the other half undone. One failed
3868
+ * POST parked the agent for the server's whole six-hour expiry, holding a
3869
+ * cap slot the entire time, and then expired into "nobody ran it" — a false
3870
+ * sentence about a turn this machine finished. The server's settle is
3871
+ * idempotent (conditional on the row still being pending), so a re-offer of
3872
+ * a held turn re-POSTs the stored body instead: safe, and it lands the
3873
+ * moment the network heals rather than six hours later.
3874
+ *
3875
+ * BOUNDED by the roster itself: a held body's clock is refreshed while the
3876
+ * server keeps offering its turn, and once offering stops — settled by the
3877
+ * re-POST, or expired server-side — the grace below is all that keeps it.
3878
+ */
3879
+ const agentReported = new Map(); // turnId -> { body, at }
3880
+ const AGENT_REPORT_GRACE_MS = 30 * 60_000;
3813
3881
 
3814
3882
  const postAgentTurn = async (body) => {
3815
- agentReported.add(String(body.turnId));
3883
+ const turnId = String(body.turnId);
3884
+ agentReported.set(turnId, { body, at: Date.now() });
3816
3885
  try {
3817
3886
  const res = await fetch(AGENT_TURN_DONE_URL, {
3818
3887
  method: 'POST',
@@ -3825,14 +3894,19 @@ export function createWorkManager({
3825
3894
  body: JSON.stringify(body),
3826
3895
  });
3827
3896
  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));
3897
+ // Landed, or REFUSED: a 4xx is the server saying this settle will never
3898
+ // be accepted (expired, already settled, unknown turn), and re-POSTing a
3899
+ // refusal forever is the wedge wearing a retry's clothes. 408/429 stay
3900
+ // retryable, the same split `postSettle` makes for a tab.
3901
+ if (
3902
+ res.ok ||
3903
+ (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
3904
+ )
3905
+ agentReported.delete(turnId);
3831
3906
  return j?.data ?? null;
3832
3907
  } 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.
3908
+ // Unsettled a network error, so the body STAYS held and the next
3909
+ // re-offer retries the POST rather than the CLI.
3836
3910
  return null;
3837
3911
  }
3838
3912
  };
@@ -3953,7 +4027,10 @@ export function createWorkManager({
3953
4027
  return;
3954
4028
  }
3955
4029
 
3956
- await inPlace(place, false, async () => {
4030
+ // A WRITER on the agent's own place see the lane header. Only `a-<id>`
4031
+ // places: those are agents' by construction, and anything else here would
4032
+ // be a tab's directory, where a turn is a reader by the product's own law.
4033
+ await inPlace(place, place.startsWith('a-'), async () => {
3957
4034
  const dir = placeWtFor(place);
3958
4035
  if (!dir) {
3959
4036
  // No worktree and none could be cut. `nothing` rather than an invented
@@ -4148,13 +4225,46 @@ export function createWorkManager({
4148
4225
  };
4149
4226
 
4150
4227
  const processAgentTurnJobs = (jobs) => {
4151
- if (!Array.isArray(jobs) || jobs.length === 0) return;
4152
- for (const job of jobs.slice(0, 4)) {
4228
+ const list = Array.isArray(jobs) ? jobs : [];
4229
+ if (agentReported.size) {
4230
+ // The roster is the held bodies' clock: an offered turn is still pending
4231
+ // server-side and worth retrying; one the roster stopped naming was
4232
+ // settled or expired, and holding its body past a generous grace would
4233
+ // grow this map for the life of the process. The server's own expiry is
4234
+ // the true bound — the grace only covers its POST racing a final offer.
4235
+ const offered = new Set(list.map((j) => String(j?.id || '')));
4236
+ const now = Date.now();
4237
+ for (const [id, held] of agentReported) {
4238
+ if (offered.has(id)) held.at = now;
4239
+ else if (now - held.at > AGENT_REPORT_GRACE_MS) agentReported.delete(id);
4240
+ }
4241
+ }
4242
+ for (const job of list.slice(0, 4)) {
4153
4243
  const id = String(job?.id || '');
4154
4244
  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;
4245
+ const held = agentReported.get(id);
4246
+ if (held) {
4247
+ // Already RAN here; only the settle is outstanding. Re-POST the held
4248
+ // body — never the CLI, which would spend the operator's quota again
4249
+ // and write a second set of commits. The reply can still carry the one
4250
+ // instruction a settle can (`review: true`, the queue just emptied),
4251
+ // so the project's check runs from here too, under the same writer
4252
+ // lock the turn itself would have held.
4253
+ agentTurns.add(id);
4254
+ void (async () => {
4255
+ const reply = await postAgentTurn(held.body);
4256
+ const place = String(job.placeId || '');
4257
+ const wt = typeof held.body.worktree === 'string' ? held.body.worktree : null;
4258
+ if (reply?.review === true && isSafePathSegment(place) && wt && existsSync(wt)) {
4259
+ await inPlace(place, place.startsWith('a-'), () =>
4260
+ runCheck(String(job.agentId || ''), wt)
4261
+ );
4262
+ }
4263
+ })()
4264
+ .catch(() => {})
4265
+ .finally(() => agentTurns.delete(id));
4266
+ continue;
4267
+ }
4158
4268
  if (!job.agentId || !job.placeId) continue;
4159
4269
  agentTurns.add(id);
4160
4270
  void runAgentTurn(job).finally(() => agentTurns.delete(id));
@@ -4382,8 +4492,8 @@ export function createWorkManager({
4382
4492
  /**
4383
4493
  * A merge COMMIT needs a git identity and the machine may have none. Prefer
4384
4494
  * 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".
4495
+ * ship's merge keeps, so a bare machine does not fail the fold with
4496
+ * "Please tell me who you are".
4387
4497
  */
4388
4498
  const gitMerge = (args, cwd) => {
4389
4499
  let idEnv = null;
@@ -4409,18 +4519,37 @@ export function createWorkManager({
4409
4519
  const agentId = String(job.agentId);
4410
4520
  const place = String(job.placeId || '');
4411
4521
  if (!isSafePathSegment(place)) {
4522
+ // Before the claim, and before `report` exists — this one is not covered
4523
+ // by the belt below because there is nothing yet to be un-reported.
4412
4524
  await postAgentMerge({ agentId, ok: false, detail: 'the agent has no worktree here' });
4413
4525
  return;
4414
4526
  }
4415
4527
  if (!(await claimAgentMerge(agentId))) return;
4416
4528
 
4529
+ /**
4530
+ * NO EXIT PATH MAY LEAVE A CLAIMED MERGE UNREPORTED — the same belt the
4531
+ * ship path carries, and this function did not.
4532
+ *
4533
+ * Every branch below reports, but an UNEXPECTED throw reports nothing:
4534
+ * several `git()` calls in here are bare, and `git()` is `execFileSync`,
4535
+ * which throws on any non-zero exit. The agent then sits in `merging`,
4536
+ * which refuses Stop and refuses an answer, until an expiry fires — with
4537
+ * nothing anywhere saying what went wrong.
4538
+ */
4539
+ let reported = false;
4540
+ const report = async (body) => {
4541
+ reported = true;
4542
+ await postAgentMerge(body);
4543
+ };
4544
+
4417
4545
  // A WRITER on the place, exactly as a ship is. It folds base in and pushes
4418
4546
  // with git in that directory, and no CLI can coordinate with something it
4419
4547
  // does not know exists.
4420
- await inPlace(place, true, async () => {
4548
+ try {
4549
+ await inPlace(place, true, async () => {
4421
4550
  const wt = join(baseDir, 'sessions', place);
4422
4551
  if (!existsSync(wt)) {
4423
- await postAgentMerge({ agentId, ok: false, detail: 'the worktree is gone' });
4552
+ await report({ agentId, ok: false, detail: 'the worktree is gone' });
4424
4553
  return;
4425
4554
  }
4426
4555
  try {
@@ -4437,7 +4566,7 @@ export function createWorkManager({
4437
4566
  try {
4438
4567
  gitMerge(['merge', '--no-edit', baseRef()], wt);
4439
4568
  } catch (e) {
4440
- await postAgentMerge({
4569
+ await report({
4441
4570
  agentId,
4442
4571
  ok: false,
4443
4572
  detail: envScrub(String(e?.message || e)).slice(0, 2000),
@@ -4460,7 +4589,7 @@ export function createWorkManager({
4460
4589
  if (!branch) {
4461
4590
  // A detached HEAD names no branch, so there is nothing to merge and
4462
4591
  // 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' });
4592
+ await report({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
4464
4593
  return;
4465
4594
  }
4466
4595
  const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
@@ -4470,7 +4599,7 @@ export function createWorkManager({
4470
4599
  // Already on base — an idempotent re-offer, or an agent that changed
4471
4600
  // nothing. Reported as a SUCCESS: the branch's work is on base, which
4472
4601
  // is what the caller is asking about.
4473
- await postAgentMerge({ agentId, ok: true, sha: tip || undefined });
4602
+ await report({ agentId, ok: true, sha: tip || undefined });
4474
4603
  return;
4475
4604
  }
4476
4605
  /**
@@ -4504,7 +4633,7 @@ export function createWorkManager({
4504
4633
  timeout: 20_000,
4505
4634
  });
4506
4635
  } catch (e) {
4507
- await postAgentMerge({
4636
+ await report({
4508
4637
  agentId,
4509
4638
  ok: false,
4510
4639
  detail:
@@ -4516,7 +4645,7 @@ export function createWorkManager({
4516
4645
  }
4517
4646
  const prBase = baseBranchName(baseRef());
4518
4647
  if (branch === prBase) {
4519
- await postAgentMerge({
4648
+ await report({
4520
4649
  agentId,
4521
4650
  ok: false,
4522
4651
  detail: `this agent is on the base branch (${prBase}) — there is nothing to open a pull request from`,
@@ -4531,19 +4660,34 @@ export function createWorkManager({
4531
4660
  // token in its userinfo — so this one line is the only place on the
4532
4661
  // agent merge path that can leak a credential into a stored,
4533
4662
  // team-visible `mergeError`.
4534
- await postAgentMerge({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
4663
+ await report({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
4535
4664
  return;
4536
4665
  }
4537
4666
  let prUrl = null;
4538
4667
  try {
4539
4668
  const j = JSON.parse(
4540
- execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
4669
+ execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state,baseRefName'], {
4541
4670
  cwd: repoRoot,
4542
4671
  stdio: ['ignore', 'pipe', 'pipe'],
4543
4672
  timeout: 30_000,
4544
4673
  }).toString()
4545
4674
  );
4546
- if (j?.state === 'OPEN' && typeof j?.url === 'string') prUrl = j.url.trim();
4675
+ if (j?.state === 'OPEN' && typeof j?.url === 'string') {
4676
+ // Adoptable only when it points at the project's own base. A PR
4677
+ // somebody opened by hand against another branch would otherwise
4678
+ // be merged INTO that branch, and the ok settle would claim work
4679
+ // reached base that landed somewhere else entirely. Refused only
4680
+ // on a MEASURED mismatch — an absent field adopts as before.
4681
+ if (typeof j?.baseRefName === 'string' && j.baseRefName !== prBase) {
4682
+ await report({
4683
+ agentId,
4684
+ ok: false,
4685
+ detail: `the open pull request for ${branch} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
4686
+ });
4687
+ return;
4688
+ }
4689
+ prUrl = j.url.trim();
4690
+ }
4547
4691
  } catch {
4548
4692
  /* no PR for this branch at all — created below */
4549
4693
  }
@@ -4559,7 +4703,7 @@ export function createWorkManager({
4559
4703
  .trim();
4560
4704
  prUrl = out.split('\n').filter(Boolean).pop() ?? null;
4561
4705
  } catch (e) {
4562
- await postAgentMerge({ agentId, ok: false, detail: ghFirstLine(e) });
4706
+ await report({ agentId, ok: false, detail: ghFirstLine(e) });
4563
4707
  return;
4564
4708
  }
4565
4709
  }
@@ -4574,7 +4718,7 @@ export function createWorkManager({
4574
4718
  // Already merged is a SUCCESS: a re-offered job, or somebody merged
4575
4719
  // it in the browser. The observer closes the cards either way.
4576
4720
  if (!/already merged/i.test(line)) {
4577
- await postAgentMerge({
4721
+ await report({
4578
4722
  agentId,
4579
4723
  ok: false,
4580
4724
  detail:
@@ -4583,11 +4727,46 @@ export function createWorkManager({
4583
4727
  return;
4584
4728
  }
4585
4729
  }
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 });
4730
+ // VERIFY before reporting ok: modern gh exits 0 on an already-MERGED
4731
+ // PR, and on a repo with a merge queue or auto-merge it exits 0 after
4732
+ // ENQUEUEING — in both, "merged" is a claim about the future. The tip
4733
+ // being an ancestor of base is the fact `ok` asserts, and the server
4734
+ // closes every delivered card on this sha the moment it hears it — so
4735
+ // measure it, with one short retry for the fetch racing GitHub's
4736
+ // merge commit. The same guard the session PR path carries.
4737
+ const tipOnBase = () => {
4738
+ try {
4739
+ git(['merge-base', '--is-ancestor', tip, baseRef()], repoRoot);
4740
+ return true;
4741
+ } catch {
4742
+ return false;
4743
+ }
4744
+ };
4745
+ let landedOnBase = false;
4746
+ for (let attempt = 0; attempt < 2 && !landedOnBase; attempt++) {
4747
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
4748
+ try {
4749
+ git(['fetch', 'origin', '--quiet'], repoRoot);
4750
+ } catch {
4751
+ /* offline — the check below answers from what we have */
4752
+ }
4753
+ landedOnBase = tipOnBase();
4754
+ }
4755
+ if (!landedOnBase) {
4756
+ await report({
4757
+ agentId,
4758
+ ok: false,
4759
+ detail:
4760
+ "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." +
4761
+ (prUrl && PR_URL_RE.test(prUrl) ? ` The pull request is at ${prUrl}.` : ''),
4762
+ });
4763
+ return;
4764
+ }
4765
+ // THE TIP, not the merge commit GitHub made: the tip identifies the
4766
+ // state we asked to be merged, which is what the receipt is for — the
4767
+ // cards themselves close when the landed observer sees the commits
4768
+ // arrive on base.
4769
+ await report({ agentId, ok: true, sha: tip });
4591
4770
  onRepoChanged();
4592
4771
  landed.observe();
4593
4772
  return;
@@ -4607,17 +4786,29 @@ export function createWorkManager({
4607
4786
  warn,
4608
4787
  });
4609
4788
  } catch (e) {
4610
- await postAgentMerge({
4789
+ await report({
4611
4790
  agentId,
4612
4791
  ok: false,
4613
4792
  detail: envScrub(String(e?.message || e)).slice(0, 2000),
4614
4793
  });
4615
4794
  return;
4616
4795
  }
4617
- await postAgentMerge({ agentId, ok: true, sha: tip });
4796
+ await report({ agentId, ok: true, sha: tip });
4618
4797
  onRepoChanged();
4619
4798
  landed.observe();
4620
- });
4799
+ });
4800
+ } finally {
4801
+ if (!reported) {
4802
+ // Belt over braces. A merge this daemon claimed and cannot account for
4803
+ // is a FAILURE, said out loud, so the agent goes back to Review with a
4804
+ // reason instead of waiting out an expiry that blames nobody.
4805
+ await postAgentMerge({
4806
+ agentId,
4807
+ ok: false,
4808
+ detail: 'the merge did not complete — check the daemon log',
4809
+ }).catch(() => {});
4810
+ }
4811
+ }
4621
4812
  };
4622
4813
 
4623
4814
  const processAgentMergeJobs = (jobs) => {
@@ -4687,6 +4878,10 @@ export function createWorkManager({
4687
4878
  // into a card nobody can explain.
4688
4879
  agentTurns.size > 0 ||
4689
4880
  agentMerges.size > 0 ||
4881
+ // A finished agent turn whose settle has not landed lives only in this
4882
+ // process; a restart here is the six-hour park the held body exists to
4883
+ // prevent. Same reason the tab's report queues are below.
4884
+ agentReported.size > 0 ||
4690
4885
  shipping.size > 0 ||
4691
4886
  workChildren.size > 0 ||
4692
4887
  workAnswering.size > 0 ||
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { existsSync, statSync, readFileSync } from 'node:fs';
29
29
  import { join } from 'node:path';
30
- import { git } from './git.mjs';
30
+ import { git, gitRaw, splitNul } from './git.mjs';
31
31
 
32
32
  /** Rows reported. The rail shows a handful; the totals below cover the rest. */
33
33
  const MAX_FILES = 20;
@@ -70,6 +70,14 @@ export function taskIdsFromMessage(body) {
70
70
  return ids;
71
71
  }
72
72
 
73
+ /**
74
+ * The %x1e/%x1f record delimiters, removed from any field that survives
75
+ * parsing. Splitting alone is not enough: git PRESERVES both bytes in a commit
76
+ * body, so a surviving field can still carry a fragment of a forged record,
77
+ * and a stripped field can never re-assemble one downstream.
78
+ */
79
+ export const stripDelims = (s) => String(s ?? '').replace(/[\x1e\x1f]/g, '');
80
+
73
81
  /**
74
82
  * The commits this branch has that base does not, with the cards they name.
75
83
  *
@@ -81,14 +89,27 @@ export function taskIdsFromMessage(body) {
81
89
  * `--no-merges`, because a merge commit describes a range rather than doing
82
90
  * work, and its trailer (if it has one) would double-book the range's own
83
91
  * commits.
92
+ *
93
+ * THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG. The %x1e and
94
+ * %x1f delimiters are formatting, not a fence: git preserves both bytes inside
95
+ * a commit BODY (verified empirically), so a crafted message can fabricate
96
+ * whole records — a self-named sha carrying task ids, exactly the receipt this
97
+ * product refuses to let an agent write about itself. rev-list prints nothing
98
+ * an author controls, so its output is the set of commits that exist: a parsed
99
+ * record whose sha is not in that set is a forgery and is dropped, a repeated
100
+ * sha is the same forgery wearing a real commit's name, and the delimiter
101
+ * bytes are stripped from every surviving field.
84
102
  */
85
103
  function branchCommits(wt, base) {
86
104
  if (!base) return [];
87
105
  const out = [];
88
106
  try {
89
- // %x1e between records, %x1f between fields — a subject and a body can
90
- // contain anything a person can type, so the delimiters must be bytes they
91
- // cannot.
107
+ const real = new Set(
108
+ git(['rev-list', '--no-merges', '-n', String(MAX_COMMITS), `${base}..HEAD`], wt)
109
+ .split('\n')
110
+ .filter(Boolean)
111
+ );
112
+ if (real.size === 0) return [];
92
113
  const raw = git(
93
114
  [
94
115
  'log',
@@ -103,9 +124,10 @@ function branchCommits(wt, base) {
103
124
  for (const rec of raw.split('\x1e')) {
104
125
  const line = rec.replace(/^\n+/, '');
105
126
  if (!line.trim()) continue;
106
- const [sha, subject, author, at, body] = line.split('\x1f');
107
- if (!sha) continue;
108
- const taskIds = taskIdsFromMessage(body);
127
+ const [sha, subject, author, at, ...bodyParts] = line.split('\x1f');
128
+ if (!real.has(sha)) continue;
129
+ real.delete(sha);
130
+ const taskIds = taskIdsFromMessage(stripDelims(bodyParts.join('\n')));
109
131
  if (taskIds.length === 0) continue;
110
132
  let additions = 0;
111
133
  let deletions = 0;
@@ -125,9 +147,9 @@ function branchCommits(wt, base) {
125
147
  // Clamped to the server's zod caps, same rule as everything else in
126
148
  // this file: one over-cap string 400s the whole batch.
127
149
  sha: sha.slice(0, 64),
128
- subject: (subject ?? '').slice(0, 200),
129
- author: (author ?? '').slice(0, 80),
130
- at: (at ?? '').slice(0, 40),
150
+ subject: stripDelims(subject).slice(0, 200),
151
+ author: stripDelims(author).slice(0, 80),
152
+ at: stripDelims(at).slice(0, 40),
131
153
  additions,
132
154
  deletions,
133
155
  taskIds: taskIds.slice(0, 8),
@@ -202,13 +224,25 @@ export function worktreeDiff(wt, baseRef) {
202
224
  };
203
225
 
204
226
  // Tracked: working tree vs base. `git diff <base>` (no --cached, no second
205
- // ref) is exactly "everything this session did", committed or not.
227
+ // ref) is exactly "everything this session did", committed or not. `-z`, for
228
+ // the reason gitRaw exists: git's LINE-based output C-quotes any path that
229
+ // is not plain ASCII, so an accented filename arrives as "n\303\251w.txt" —
230
+ // a string that is not the path and reads as escaped garbage in the rail.
206
231
  try {
207
- const raw = git(['diff', '--numstat', base || 'HEAD'], wt);
208
- for (const line of raw.split('\n')) {
209
- if (!line.trim()) continue;
210
- const [a, d, ...rest] = line.split('\t');
211
- const path = rest.join('\t');
232
+ // `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
233
+ // but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
234
+ // then the new one. An empty path is therefore the rename marker and the
235
+ // next two fields belong to it — read line-wise, a rename would report a
236
+ // file literally named "old => new".
237
+ const fields = splitNul(gitRaw(['diff', '--numstat', '-z', base || 'HEAD', '--'], wt));
238
+ for (let i = 0; i < fields.length; i++) {
239
+ const [a, d, ...rest] = fields[i].split('\t');
240
+ let path = rest.join('\t');
241
+ if (!path) {
242
+ path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
243
+ i += 2;
244
+ if (!path) continue;
245
+ }
212
246
  const binary = a === '-' || d === '-';
213
247
  push(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
214
248
  }
@@ -217,11 +251,12 @@ export function worktreeDiff(wt, baseRef) {
217
251
  }
218
252
 
219
253
  // Untracked, minus everything gitignored — new files are the most visible
220
- // work a session does and they would otherwise show as nothing at all.
254
+ // work a session does and they would otherwise show as nothing at all. `-z`
255
+ // here is not cosmetic: a C-quoted untracked name fails the statSync below,
256
+ // and the catch swallowed the row — a session's brand-new `café.txt` simply
257
+ // vanished from the rail.
221
258
  try {
222
- const others = git(['ls-files', '--others', '--exclude-standard'], wt)
223
- .split('\n')
224
- .filter(Boolean);
259
+ const others = splitNul(gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], wt));
225
260
  for (const path of others.slice(0, MAX_UNTRACKED_SCAN)) {
226
261
  try {
227
262
  const full = join(wt, path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.78.0",
3
+ "version": "0.78.1",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {