flowviant 0.66.0 → 0.67.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/fleet.mjs CHANGED
@@ -319,7 +319,7 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
319
319
  repoStateScanAt = Date.now();
320
320
  let payload;
321
321
  try {
322
- const state = repoState(repoRoot, baseRef);
322
+ const state = repoState(repoRoot, getBaseRef());
323
323
  if (!state) return; // not readable — say nothing rather than say "none"
324
324
  payload = JSON.stringify(state);
325
325
  } catch {
@@ -399,7 +399,19 @@ export async function runFleetDaemon() {
399
399
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
400
400
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
401
401
  const repoRoot = repoRootOrDie();
402
- const baseRef = detectBaseRef(repoRoot);
402
+ /**
403
+ * WHERE SHIP LANDS. Detected at startup, then OVERRIDDEN by the roster when a
404
+ * human has chosen one (`projects.baseBranch`).
405
+ *
406
+ * A `let` and a getter rather than a const, because the answer can change
407
+ * while the daemon runs — and because the detected value itself is fragile:
408
+ * with no `origin/HEAD` set, `detectBaseRef` falls back to
409
+ * `origin/<whatever was checked out at startup>`, which froze for the life of
410
+ * the process. A stored value is the fix; this is the wiring that lets it
411
+ * reach the code that merges.
412
+ */
413
+ let baseRef = detectBaseRef(repoRoot);
414
+ const getBaseRef = () => baseRef;
403
415
  info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
404
416
  // WHICH PROJECT, before anything connects — the roster names it again a few
405
417
  // seconds later with the server's word, but "which project is this daemon
@@ -815,7 +827,7 @@ export async function runFleetDaemon() {
815
827
  } = createWorkManager({
816
828
  repoRoot,
817
829
  baseDir,
818
- baseRef,
830
+ getBaseRef,
819
831
  getMcpUrl: () => mcpUrl,
820
832
  getLeaseTtl: () => leaseTtlSeconds,
821
833
  });
@@ -1542,6 +1554,20 @@ export async function runFleetDaemon() {
1542
1554
  // `git worktree remove` would happily pull the directory out from under a
1543
1555
  // running node process, which then serves bytes from open file handles in
1544
1556
  // a directory that no longer exists, with no error anywhere.
1557
+ /**
1558
+ * WHERE SHIP LANDS, if a human has chosen. Absence means "you decide" —
1559
+ * the state every daemon was in before this existed, and what an
1560
+ * unconfigured project still means — so it must NOT clear a detection.
1561
+ * Announced on change, because a silent switch of merge target is the one
1562
+ * thing worse than not offering the choice at all.
1563
+ */
1564
+ if (typeof roster.baseBranch === 'string' && roster.baseBranch.trim()) {
1565
+ const want = `origin/${baseBranchName(roster.baseBranch.trim())}`;
1566
+ if (want !== baseRef) {
1567
+ note(`base · ${want} ${c.dim('(set for this project)')}`);
1568
+ baseRef = want;
1569
+ }
1570
+ }
1545
1571
  // A session another daemon on this credential is serving is NOT a closed
1546
1572
  // tab. Without this the daemon that lost the lease removes the worktree the
1547
1573
  // winner is working in — absence would mean "somebody else won" instead of
@@ -1662,7 +1688,7 @@ export async function runFleetDaemon() {
1662
1688
  // machines). Config report is cheap + dedup'd; jobs are single-flight.
1663
1689
  if (roster.env?.deployAuthorized) {
1664
1690
  void reportDeployConfig(repoRoot);
1665
- processDeployJobs(roster.deployJobs, { repoRoot, baseRef, myPubB64 });
1691
+ processDeployJobs(roster.deployJobs, { repoRoot, baseRef: getBaseRef(), myPubB64 });
1666
1692
  }
1667
1693
 
1668
1694
  // Stop workers whose agent left the roster (removed in the app).
package/bin/lib/git.mjs CHANGED
@@ -98,6 +98,32 @@ export function detectBaseRef(repoRoot) {
98
98
  } catch {
99
99
  /* origin/HEAD not set */
100
100
  }
101
+ /**
102
+ * NO `origin/HEAD`. Prefer a CONVENTION over an accident.
103
+ *
104
+ * This used to fall straight through to `origin/<whatever is checked out
105
+ * right now>` — which, since the result is computed once at daemon start and
106
+ * held for the life of the process, meant starting the daemon while you
107
+ * happened to be on `staging` silently made staging the merge target for
108
+ * every ship until you restarted. Nothing said so.
109
+ *
110
+ * A remote branch actually called `main` or `master` is a far better guess
111
+ * than the branch you were standing on, and unlike that one it does not
112
+ * depend on when the process booted. The old behaviour survives as the last
113
+ * resort, because a repo with neither is a repo where we genuinely have
114
+ * nothing better.
115
+ *
116
+ * The real fix is that a human can now SET it (`projects.baseBranch`), which
117
+ * overrides all of this. This just stops the unset case being arbitrary.
118
+ */
119
+ for (const conventional of ['origin/main', 'origin/master']) {
120
+ try {
121
+ git(['rev-parse', '--verify', '--quiet', `refs/remotes/${conventional}`], repoRoot);
122
+ return conventional;
123
+ } catch {
124
+ /* not this one */
125
+ }
126
+ }
101
127
  try {
102
128
  return `origin/${git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)}`;
103
129
  } catch {
@@ -165,6 +165,7 @@ export function repoState(repoRoot, baseRef) {
165
165
  const listening = listenersIn(repoRoot);
166
166
  const wt = worktrees ?? [];
167
167
  const br = branches ?? [];
168
+ const sessionRows = br.filter((b) => b.session);
168
169
  return {
169
170
  base: baseRef,
170
171
  worktrees: wt.slice(0, MAX_WORKTREES),
@@ -173,7 +174,25 @@ export function repoState(repoRoot, baseRef) {
173
174
  // part somebody is actually working in.
174
175
  branches: br.slice(0, MAX_BRANCHES),
175
176
  branchesTotal: br.length,
176
- sessionBranches: br.filter((b) => b.session).length,
177
+ sessionBranches: sessionRows.length,
178
+ /**
179
+ * …AND HOW MANY OF THEM STILL HOLD WORK.
180
+ *
181
+ * "40 branches" is meaningful to nobody. "3 branches holding work you
182
+ * haven't shipped" is meaningful to everybody, and it is the only half of
183
+ * the count anyone can act on. `ahead` is commits this branch has that base
184
+ * does not — already measured above by `ahead-behind`, so this costs no
185
+ * extra git call.
186
+ *
187
+ * OMITTED, never guessed, when the measurement is missing: an older git
188
+ * takes the fallback format in `readBranches` and reports no `ahead` at
189
+ * all, and a count of zero unshipped branches would then be a claim nobody
190
+ * measured. Absent means "could not tell"; the surface renders the flat
191
+ * count instead.
192
+ */
193
+ ...(sessionRows.every((b) => typeof b.ahead === 'number')
194
+ ? { sessionBranchesUnshipped: sessionRows.filter((b) => b.ahead > 0).length }
195
+ : {}),
177
196
  listening,
178
197
  // "Nothing is listening" and "this machine cannot look" (Windows, a failed
179
198
  // scan) are the same empty array without this — and the second must never
@@ -0,0 +1,59 @@
1
+ import { baseBranchName } from './git.mjs';
2
+
3
+ /**
4
+ * RETIRE A BRANCH FLOWVIANT MADE AND FLOWVIANT MERGED.
5
+ *
6
+ * NOT PRUNING SOMEBODY'S REPO — removing our own bookkeeping. `session/<id>`
7
+ * is an artifact this daemon created, named and merged; the driver's work is
8
+ * the commits, and after a `--no-ff` ship those are on base. The standing law
9
+ * that the Repository block reports and never prunes is untouched: it was
10
+ * written for refs of UNKNOWN provenance — a branch the agent cut mid-turn, a
11
+ * worktree a crash left behind — and this is the one category that is
12
+ * provably ours.
13
+ *
14
+ * WHY IT MATTERS MORE THAN TIDINESS: a reporting surface only works if what
15
+ * it reports is rare. Every merged session branch used to accumulate forever,
16
+ * so "is claude polluting the branches" could not be answered from a list
17
+ * dominated by our own litter. Stop littering and what remains is worth
18
+ * reading.
19
+ *
20
+ * `git branch -d` IS THE GUARD, deliberately, rather than a stack of checks
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.
27
+ *
28
+ * ORDERING IS LOAD-BEARING. This must not run while a ship report is still
29
+ * undelivered. Ship's idempotency path recovers from a lost report by asking
30
+ * `branchExists && ancestorOfBase(branch)`; with the branch gone a re-offered
31
+ * job answers "nothing to ship — this session has no branch on this machine"
32
+ * for work that shipped, and the server's reconciliation backstop silently
33
+ * never books the commits no card claimed.
34
+ *
35
+ * Local only. Ship pushes base and has never pushed `session/*`, so there is
36
+ * nothing to clean on a remote.
37
+ */
38
+ export function sweepMergedBranch(sessionId, { git, repoRoot, baseRef, note, isReportPending }) {
39
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(sessionId ?? ''))) return false;
40
+ // The report has not landed. See ORDERING above.
41
+ if (isReportPending?.(sessionId)) return false;
42
+ const name = `session/${sessionId}`;
43
+ try {
44
+ git(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], repoRoot);
45
+ } catch {
46
+ return false; // already gone, or never existed
47
+ }
48
+ try {
49
+ git(['branch', '-d', name], repoRoot);
50
+ } catch {
51
+ // Unmerged, or checked out somewhere. Both are correct reasons to keep it,
52
+ // and both are git's answer rather than ours.
53
+ return false;
54
+ }
55
+ // NARRATED, like every other sweep: a deletion with no trace in the log
56
+ // cannot be diagnosed from either end.
57
+ note?.(`retired ${name} — already merged into ${baseBranchName(baseRef)}`);
58
+ return true;
59
+ }
package/bin/lib/work.mjs CHANGED
@@ -42,6 +42,7 @@ import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.
42
42
  import { listenersIn, listenersSupported } from './listeners.mjs';
43
43
  import { processesInGroups, liveGroups, processesSupported } from './processes.mjs';
44
44
  import { createPlaceLock } from './placeLock.mjs';
45
+ import { sweepMergedBranch } from './shipSweep.mjs';
45
46
  import { openTunnel } from './preview.mjs';
46
47
  import { c, note, ok, warn } from './ui.mjs';
47
48
  import { mcpFor, runTurn } from './claude.mjs';
@@ -100,7 +101,18 @@ function brainFor(job) {
100
101
  return out;
101
102
  }
102
103
 
103
- export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLeaseTtl }) {
104
+ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, getLeaseTtl }) {
105
+ /**
106
+ * WHERE SHIP LANDS, read fresh every time rather than captured at startup.
107
+ *
108
+ * A getter, like `getMcpUrl` and `getLeaseTtl` beside it, because the answer
109
+ * can now change while the daemon runs: a human sets `projects.baseBranch`
110
+ * and the next roster poll carries it. Captured by value this would be
111
+ * whatever `origin/HEAD` said the moment the process booted — which is also
112
+ * the shape of the bug it replaces, where an unset `origin/HEAD` froze
113
+ * `origin/<branch you happened to be on>` for the life of the daemon.
114
+ */
115
+ const baseRef = () => getBaseRef();
104
116
  const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
105
117
  const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
106
118
  const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
@@ -227,6 +239,9 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
227
239
  } else {
228
240
  pendingShipReports.delete(sessionId);
229
241
  reportBackoff.delete(sessionId);
242
+ // The report has landed, so the idempotency path no longer needs the
243
+ // branch to exist. See `sweepMergedSessionBranch`.
244
+ sweepMergedSessionBranch(sessionId);
230
245
  }
231
246
  return r;
232
247
  };
@@ -423,7 +438,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
423
438
  const place = placeOf(sessionId);
424
439
  if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
425
440
  const wt = placeDir(sessionId);
426
- const d = worktreeDiff(wt, baseRef);
441
+ const d = worktreeDiff(wt, baseRef());
427
442
  if (!d) return null;
428
443
  // WHAT IS LISTENING in this worktree, attributed by the CWD of the process
429
444
  // holding the socket. It rides the sweep the daemon already makes rather
@@ -972,6 +987,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
972
987
  else if (r !== 'retry') {
973
988
  pendingShipReports.delete(id);
974
989
  reportBackoff.delete(id);
990
+ // Delivered late is still delivered — same sweep as the immediate
991
+ // path, and it must be here too or a report that needed a retry
992
+ // would leave its branch behind forever.
993
+ sweepMergedSessionBranch(id);
975
994
  }
976
995
  }
977
996
  } finally {
@@ -1071,7 +1090,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1071
1090
  // would hand it a repo state it has never seen. Everything else is
1072
1091
  // unchanged, the attach fallback included: a surviving branch already
1073
1092
  // chose its base, and re-basing it here would move committed work.
1074
- const at = baseAt || baseRef;
1093
+ const at = baseAt || baseRef();
1075
1094
  try {
1076
1095
  git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
1077
1096
  } catch {
@@ -1442,6 +1461,18 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1442
1461
  * session's own id IS its default place. */
1443
1462
  const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
1444
1463
 
1464
+ /** See `shipSweep.mjs`. Bound to this manager's repo, base and report queue. */
1465
+ const sweepMergedSessionBranch = (sessionId) =>
1466
+ sweepMergedBranch(sessionId, {
1467
+ git,
1468
+ repoRoot,
1469
+ baseRef: baseRef(),
1470
+ note,
1471
+ // The report queue is consulted at CALL time, never captured — a sweep
1472
+ // scheduled while a report was outstanding must still see it land.
1473
+ isReportPending: (id) => pendingShipReports.has(id),
1474
+ });
1475
+
1445
1476
  const retireWorkSessions = (activeIds, heldElsewhere) => {
1446
1477
  if (!Array.isArray(activeIds)) return;
1447
1478
  // Sessions ANOTHER daemon on this credential is serving. They are absent
@@ -1479,9 +1510,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1479
1510
  // it, closed tab or not. (The non-force remove would refuse anyway;
1480
1511
  // the explicit check keeps the intent legible.)
1481
1512
  if (git(['status', '--porcelain'], wt) !== '') continue;
1482
- git(['worktree', 'remove', wt], repoRoot); // non-force; the branch survives
1513
+ git(['worktree', 'remove', wt], repoRoot); // non-force
1483
1514
  workTokens.delete(id);
1484
1515
  removed++;
1516
+ // NOW the branch can be judged. While this worktree existed the branch
1517
+ // was checked out in it, so `git branch -d` refused on every earlier
1518
+ // attempt — a tab that shipped and then closed would otherwise leave
1519
+ // its merged branch behind forever, which is the common case.
1520
+ // Unshipped work still refuses here: `-d` is what decides.
1521
+ sweepMergedSessionBranch(id);
1485
1522
  } catch {
1486
1523
  /* not cleanly removable — leave it */
1487
1524
  }
@@ -2191,7 +2228,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2191
2228
  }
2192
2229
  const ancestorOfBase = (ref) => {
2193
2230
  try {
2194
- git(['merge-base', '--is-ancestor', ref, baseRef], repoRoot);
2231
+ git(['merge-base', '--is-ancestor', ref, baseRef()], repoRoot);
2195
2232
  return true;
2196
2233
  } catch {
2197
2234
  return false;
@@ -2240,7 +2277,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2240
2277
  } catch {
2241
2278
  /* not there — fine */
2242
2279
  }
2243
- git(['worktree', 'add', '--detach', tmp, baseRef], repoRoot);
2280
+ git(['worktree', 'add', '--detach', tmp, baseRef()], repoRoot);
2244
2281
  gitMerge(
2245
2282
  [
2246
2283
  'merge',
@@ -2251,7 +2288,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2251
2288
  ],
2252
2289
  tmp
2253
2290
  );
2254
- git(['push', 'origin', `HEAD:${baseBranchName(baseRef)}`], tmp);
2291
+ git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmp);
2255
2292
  } finally {
2256
2293
  try {
2257
2294
  git(['worktree', 'remove', '--force', tmp], repoRoot);
@@ -2272,7 +2309,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2272
2309
  const tip = git(['rev-parse', branch], repoRoot);
2273
2310
  let commits = [];
2274
2311
  try {
2275
- const m = git(['log', baseRef, '--merges', '--format=%H %P', '-n', '500'], repoRoot)
2312
+ const m = git(['log', baseRef(), '--merges', '--format=%H %P', '-n', '500'], repoRoot)
2276
2313
  .split('\n')
2277
2314
  .map((l) => l.trim().split(' '))
2278
2315
  .find((p) => p.length >= 3 && p[2] === tip);
@@ -2283,7 +2320,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2283
2320
  await done({
2284
2321
  ok: true,
2285
2322
  commits,
2286
- note: `${baseBranchName(baseRef)} already contains this session's branch — nothing new to merge`,
2323
+ note: `${baseBranchName(baseRef())} already contains this session's branch — nothing new to merge`,
2287
2324
  });
2288
2325
  ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
2289
2326
  return;
@@ -2301,7 +2338,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2301
2338
  return;
2302
2339
  }
2303
2340
  const tip = git(['rev-parse', branch], repoRoot);
2304
- const commits = logCommits(`${baseRef}..${tip}`);
2341
+ const commits = logCommits(`${baseRef()}..${tip}`);
2305
2342
  if (commits.length === 0) {
2306
2343
  await done({
2307
2344
  ok: false,
@@ -2369,7 +2406,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2369
2406
  // Fold main into the branch FIRST: conflicts land here, in the
2370
2407
  // session's own worktree, where the next turn can resolve them.
2371
2408
  try {
2372
- gitMerge(['merge', '--no-edit', baseRef], dir.wt);
2409
+ gitMerge(['merge', '--no-edit', baseRef()], dir.wt);
2373
2410
  } catch (e) {
2374
2411
  const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
2375
2412
  // NEVER leave the session mid-merge: a MERGE_HEAD left behind puts
@@ -2400,7 +2437,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2400
2437
  // one X for both, so the ledger can never carry receipts for commits
2401
2438
  // that did not land.
2402
2439
  const tip = git(['rev-parse', branch], repoRoot);
2403
- const commits = logCommits(`${baseRef}..${tip}`);
2440
+ const commits = logCommits(`${baseRef()}..${tip}`);
2404
2441
  if (commits.length === 0) {
2405
2442
  // Post-fold this is nearly unreachable (a zero-commit branch is an
2406
2443
  // ancestor of base, settled above) — but if the branch's commits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
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": {