flowviant 0.66.0 → 0.68.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,132 @@
1
+ import { baseBranchName } from './git.mjs';
2
+
3
+ /**
4
+ * CARRY A SHIPPED TIP OUT ONTO BASE AND PUSH IT.
5
+ *
6
+ * Through a THROWAWAY DETACHED WORKTREE, which is the whole shape of this: the
7
+ * merge commit has to be made somewhere, and making it in anybody's checkout
8
+ * moves a directory somebody is working in. A detached worktree at base is
9
+ * nobody's, so the merge lands, the push goes, and the directory dies in the
10
+ * `finally` — on success, on conflict, on throw. It must die, or the next ship
11
+ * of this session trips over its corpse.
12
+ *
13
+ * `--no-ff`, NEVER squash: every delivered card carries commit shas as its
14
+ * receipts, and squashing would point all of them at commits that no longer
15
+ * exist on base.
16
+ *
17
+ * Two behaviours beyond that, and both arrived with per-person worktrees.
18
+ */
19
+ export function mergeOutward({
20
+ tip,
21
+ count,
22
+ branch,
23
+ label,
24
+ git,
25
+ gitMerge,
26
+ repoRoot,
27
+ tmpDir,
28
+ baseRef,
29
+ workingTree,
30
+ warn,
31
+ }) {
32
+ const dropTmp = () => {
33
+ try {
34
+ git(['worktree', 'remove', '--force', tmpDir], repoRoot);
35
+ } catch {
36
+ /* not there — fine */
37
+ }
38
+ };
39
+ const attempt = () => {
40
+ dropTmp();
41
+ git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
42
+ gitMerge(
43
+ ['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
44
+ tmpDir
45
+ );
46
+ git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir);
47
+ };
48
+ try {
49
+ try {
50
+ attempt();
51
+ } catch (e) {
52
+ /**
53
+ * TWO PEOPLE SHIPPED AT ONCE — retry exactly once.
54
+ *
55
+ * Ship takes a write lock on a PLACE, and since every person works in a
56
+ * directory of their own, two teammates shipping hold two DIFFERENT
57
+ * locks and nothing serializes them. Both fetch, both merge onto the same
58
+ * base in their own throwaway, and whoever pushes second is rejected
59
+ * non-fast-forward. The window is fetch-to-push, and it did not exist
60
+ * while everyone shared one directory — it arrived with the split.
61
+ *
62
+ * WITHOUT THIS the loser is told their ship FAILED, in raw git, over a
63
+ * race that resolves itself by looking again. That breaks the promise
64
+ * this path exists to keep: nobody may be left believing their work is
65
+ * or is not on base when the opposite is true.
66
+ *
67
+ * ONCE, not a loop. A second rejection is no longer a race — it is a repo
68
+ * something else is writing to continuously, and the honest answer there
69
+ * is the error. The retry re-fetches and rebuilds the throwaway from the
70
+ * NEW base, so it merges against what the winner just landed rather than
71
+ * re-pushing a stale merge. The TIP is untouched, so the receipts still
72
+ * name exactly the same commits.
73
+ */
74
+ if (!isRaceRejection(e)) throw e;
75
+ warn?.('ship: base moved under us — refetching and merging again');
76
+ try {
77
+ git(['fetch', 'origin', '--quiet'], repoRoot);
78
+ } catch {
79
+ /* offline — the retry fails honestly on the same push */
80
+ }
81
+ attempt();
82
+ }
83
+ /**
84
+ * AND BRING THE SHIP PLACE'S OWN BRANCH UP, when that branch IS base.
85
+ *
86
+ * Which is every tab belonging to the machine's OPERATOR: their place is
87
+ * the project folder, and it sits on main. Without this, main is left one
88
+ * commit behind `origin/main` the instant it ships, because the `--no-ff`
89
+ * merge exists only on the remote — and `worktreeDiff` computes `behind` as
90
+ * `HEAD..origin/main` without filtering merges, so the rail immediately
91
+ * reported "1 new on main since you branched" and listed the operator's OWN
92
+ * ship commit back to them. That inverts the entire point of that block,
93
+ * which is to show the one thing a session cannot see from inside itself.
94
+ *
95
+ * Safe by construction and never a surprise: ship already required a clean
96
+ * tree, the fold already moved this same directory under the same exclusive
97
+ * lock, and `--ff-only` can neither conflict nor write a commit.
98
+ *
99
+ * NOBODY ELSE'S CHECKOUT MOVES. A teammate's branch is not base, so this
100
+ * does nothing for them — and their branch being genuinely behind base is a
101
+ * fact the rail should keep telling them.
102
+ */
103
+ if (workingTree && branch && branch === baseBranchName(baseRef())) {
104
+ try {
105
+ git(['fetch', 'origin', '--quiet'], repoRoot);
106
+ git(['merge', '--ff-only', baseRef()], workingTree);
107
+ } catch {
108
+ /* a readout, not the ship — the merge already landed, and the next
109
+ fold picks this up either way */
110
+ }
111
+ }
112
+ } finally {
113
+ try {
114
+ dropTmp();
115
+ git(['worktree', 'prune'], repoRoot);
116
+ } catch {
117
+ /* best effort */
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Is this push failure a LOST RACE rather than a broken repo?
124
+ *
125
+ * Matched on git's own words. Deliberately narrow: anything unrecognised is
126
+ * rethrown, because retrying an unknown failure is how a real problem gets
127
+ * reported twice and understood never.
128
+ */
129
+ export function isRaceRejection(e) {
130
+ const d = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
131
+ return /non-fast-forward|\[rejected\]|fetch first|stale info/i.test(d);
132
+ }
@@ -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,8 @@ 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';
46
+ import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
45
47
  import { openTunnel } from './preview.mjs';
46
48
  import { c, note, ok, warn } from './ui.mjs';
47
49
  import { mcpFor, runTurn } from './claude.mjs';
@@ -100,7 +102,18 @@ function brainFor(job) {
100
102
  return out;
101
103
  }
102
104
 
103
- export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLeaseTtl }) {
105
+ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, getLeaseTtl }) {
106
+ /**
107
+ * WHERE SHIP LANDS, read fresh every time rather than captured at startup.
108
+ *
109
+ * A getter, like `getMcpUrl` and `getLeaseTtl` beside it, because the answer
110
+ * can now change while the daemon runs: a human sets `projects.baseBranch`
111
+ * and the next roster poll carries it. Captured by value this would be
112
+ * whatever `origin/HEAD` said the moment the process booted — which is also
113
+ * the shape of the bug it replaces, where an unset `origin/HEAD` froze
114
+ * `origin/<branch you happened to be on>` for the life of the daemon.
115
+ */
116
+ const baseRef = () => getBaseRef();
104
117
  const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
105
118
  const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
106
119
  const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
@@ -227,6 +240,9 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
227
240
  } else {
228
241
  pendingShipReports.delete(sessionId);
229
242
  reportBackoff.delete(sessionId);
243
+ // The report has landed, so the idempotency path no longer needs the
244
+ // branch to exist. See `sweepMergedSessionBranch`.
245
+ sweepMergedSessionBranch(sessionId);
230
246
  }
231
247
  return r;
232
248
  };
@@ -423,7 +439,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
423
439
  const place = placeOf(sessionId);
424
440
  if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
425
441
  const wt = placeDir(sessionId);
426
- const d = worktreeDiff(wt, baseRef);
442
+ const d = worktreeDiff(wt, baseRef());
427
443
  if (!d) return null;
428
444
  // WHAT IS LISTENING in this worktree, attributed by the CWD of the process
429
445
  // holding the socket. It rides the sweep the daemon already makes rather
@@ -972,6 +988,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
972
988
  else if (r !== 'retry') {
973
989
  pendingShipReports.delete(id);
974
990
  reportBackoff.delete(id);
991
+ // Delivered late is still delivered — same sweep as the immediate
992
+ // path, and it must be here too or a report that needed a retry
993
+ // would leave its branch behind forever.
994
+ sweepMergedSessionBranch(id);
975
995
  }
976
996
  }
977
997
  } finally {
@@ -1071,7 +1091,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1071
1091
  // would hand it a repo state it has never seen. Everything else is
1072
1092
  // unchanged, the attach fallback included: a surviving branch already
1073
1093
  // chose its base, and re-basing it here would move committed work.
1074
- const at = baseAt || baseRef;
1094
+ const at = baseAt || baseRef();
1075
1095
  try {
1076
1096
  git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
1077
1097
  } catch {
@@ -1442,6 +1462,18 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1442
1462
  * session's own id IS its default place. */
1443
1463
  const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
1444
1464
 
1465
+ /** See `shipSweep.mjs`. Bound to this manager's repo, base and report queue. */
1466
+ const sweepMergedSessionBranch = (sessionId) =>
1467
+ sweepMergedBranch(sessionId, {
1468
+ git,
1469
+ repoRoot,
1470
+ baseRef: baseRef(),
1471
+ note,
1472
+ // The report queue is consulted at CALL time, never captured — a sweep
1473
+ // scheduled while a report was outstanding must still see it land.
1474
+ isReportPending: (id) => pendingShipReports.has(id),
1475
+ });
1476
+
1445
1477
  const retireWorkSessions = (activeIds, heldElsewhere) => {
1446
1478
  if (!Array.isArray(activeIds)) return;
1447
1479
  // Sessions ANOTHER daemon on this credential is serving. They are absent
@@ -1479,9 +1511,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1479
1511
  // it, closed tab or not. (The non-force remove would refuse anyway;
1480
1512
  // the explicit check keeps the intent legible.)
1481
1513
  if (git(['status', '--porcelain'], wt) !== '') continue;
1482
- git(['worktree', 'remove', wt], repoRoot); // non-force; the branch survives
1514
+ git(['worktree', 'remove', wt], repoRoot); // non-force
1483
1515
  workTokens.delete(id);
1484
1516
  removed++;
1517
+ // NOW the branch can be judged. While this worktree existed the branch
1518
+ // was checked out in it, so `git branch -d` refused on every earlier
1519
+ // attempt — a tab that shipped and then closed would otherwise leave
1520
+ // its merged branch behind forever, which is the common case.
1521
+ // Unshipped work still refuses here: `-d` is what decides.
1522
+ sweepMergedSessionBranch(id);
1485
1523
  } catch {
1486
1524
  /* not cleanly removable — leave it */
1487
1525
  }
@@ -2191,7 +2229,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2191
2229
  }
2192
2230
  const ancestorOfBase = (ref) => {
2193
2231
  try {
2194
- git(['merge-base', '--is-ancestor', ref, baseRef], repoRoot);
2232
+ git(['merge-base', '--is-ancestor', ref, baseRef()], repoRoot);
2195
2233
  return true;
2196
2234
  } catch {
2197
2235
  return false;
@@ -2232,35 +2270,23 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2232
2270
  // Merge outward through a throwaway worktree so no checkout moves.
2233
2271
  // The throwaway dies on EVERY exit — success, conflict or throw —
2234
2272
  // or the next ship of this session trips over its corpse.
2235
- const mergeOutward = (tip, count) => {
2236
- const tmp = join(baseDir, 'ship', job.sessionId);
2237
- try {
2238
- try {
2239
- git(['worktree', 'remove', '--force', tmp], repoRoot);
2240
- } catch {
2241
- /* not there — fine */
2242
- }
2243
- git(['worktree', 'add', '--detach', tmp, baseRef], repoRoot);
2244
- gitMerge(
2245
- [
2246
- 'merge',
2247
- '--no-ff',
2248
- tip,
2249
- '-m',
2250
- `ship(${job.sessionName || job.sessionId.slice(0, 8)}): ${count} commit${count === 1 ? '' : 's'}`,
2251
- ],
2252
- tmp
2253
- );
2254
- git(['push', 'origin', `HEAD:${baseBranchName(baseRef)}`], tmp);
2255
- } finally {
2256
- try {
2257
- git(['worktree', 'remove', '--force', tmp], repoRoot);
2258
- git(['worktree', 'prune'], repoRoot);
2259
- } catch {
2260
- /* best effort */
2261
- }
2262
- }
2263
- };
2273
+ // Carry the tip out onto base and push it. See `shipMerge.mjs` for
2274
+ // the throwaway-worktree shape, the one retry when two people ship at
2275
+ // once, and why the operator's own branch is fast-forwarded after.
2276
+ const mergeOutward = (tip, count) =>
2277
+ shipMergeOutward({
2278
+ tip,
2279
+ count,
2280
+ branch,
2281
+ label: job.sessionName || job.sessionId.slice(0, 8),
2282
+ git,
2283
+ gitMerge,
2284
+ repoRoot,
2285
+ tmpDir: join(baseDir, 'ship', job.sessionId),
2286
+ baseRef,
2287
+ workingTree: placeWtFor(shipPlace)?.wt ?? null,
2288
+ warn,
2289
+ });
2264
2290
  // Idempotency: base already contains the branch tip. A re-offered
2265
2291
  // job after a lost report lands here — never a re-merge, and never
2266
2292
  // "nothing to ship" AS A FAILURE for work that in fact shipped. The
@@ -2272,7 +2298,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2272
2298
  const tip = git(['rev-parse', branch], repoRoot);
2273
2299
  let commits = [];
2274
2300
  try {
2275
- const m = git(['log', baseRef, '--merges', '--format=%H %P', '-n', '500'], repoRoot)
2301
+ const m = git(['log', baseRef(), '--merges', '--format=%H %P', '-n', '500'], repoRoot)
2276
2302
  .split('\n')
2277
2303
  .map((l) => l.trim().split(' '))
2278
2304
  .find((p) => p.length >= 3 && p[2] === tip);
@@ -2283,7 +2309,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2283
2309
  await done({
2284
2310
  ok: true,
2285
2311
  commits,
2286
- note: `${baseBranchName(baseRef)} already contains this session's branch — nothing new to merge`,
2312
+ note: `${baseBranchName(baseRef())} already contains this session's branch — nothing new to merge`,
2287
2313
  });
2288
2314
  ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
2289
2315
  return;
@@ -2301,7 +2327,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2301
2327
  return;
2302
2328
  }
2303
2329
  const tip = git(['rev-parse', branch], repoRoot);
2304
- const commits = logCommits(`${baseRef}..${tip}`);
2330
+ const commits = logCommits(`${baseRef()}..${tip}`);
2305
2331
  if (commits.length === 0) {
2306
2332
  await done({
2307
2333
  ok: false,
@@ -2369,7 +2395,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2369
2395
  // Fold main into the branch FIRST: conflicts land here, in the
2370
2396
  // session's own worktree, where the next turn can resolve them.
2371
2397
  try {
2372
- gitMerge(['merge', '--no-edit', baseRef], dir.wt);
2398
+ gitMerge(['merge', '--no-edit', baseRef()], dir.wt);
2373
2399
  } catch (e) {
2374
2400
  const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
2375
2401
  // NEVER leave the session mid-merge: a MERGE_HEAD left behind puts
@@ -2400,7 +2426,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2400
2426
  // one X for both, so the ledger can never carry receipts for commits
2401
2427
  // that did not land.
2402
2428
  const tip = git(['rev-parse', branch], repoRoot);
2403
- const commits = logCommits(`${baseRef}..${tip}`);
2429
+ const commits = logCommits(`${baseRef()}..${tip}`);
2404
2430
  if (commits.length === 0) {
2405
2431
  // Post-fold this is nearly unreachable (a zero-commit branch is an
2406
2432
  // 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.68.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": {