flowviant 0.67.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.
@@ -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
+ }
package/bin/lib/work.mjs CHANGED
@@ -43,6 +43,7 @@ import { listenersIn, listenersSupported } from './listeners.mjs';
43
43
  import { processesInGroups, liveGroups, processesSupported } from './processes.mjs';
44
44
  import { createPlaceLock } from './placeLock.mjs';
45
45
  import { sweepMergedBranch } from './shipSweep.mjs';
46
+ import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
46
47
  import { openTunnel } from './preview.mjs';
47
48
  import { c, note, ok, warn } from './ui.mjs';
48
49
  import { mcpFor, runTurn } from './claude.mjs';
@@ -2269,35 +2270,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2269
2270
  // Merge outward through a throwaway worktree so no checkout moves.
2270
2271
  // The throwaway dies on EVERY exit — success, conflict or throw —
2271
2272
  // or the next ship of this session trips over its corpse.
2272
- const mergeOutward = (tip, count) => {
2273
- const tmp = join(baseDir, 'ship', job.sessionId);
2274
- try {
2275
- try {
2276
- git(['worktree', 'remove', '--force', tmp], repoRoot);
2277
- } catch {
2278
- /* not there — fine */
2279
- }
2280
- git(['worktree', 'add', '--detach', tmp, baseRef()], repoRoot);
2281
- gitMerge(
2282
- [
2283
- 'merge',
2284
- '--no-ff',
2285
- tip,
2286
- '-m',
2287
- `ship(${job.sessionName || job.sessionId.slice(0, 8)}): ${count} commit${count === 1 ? '' : 's'}`,
2288
- ],
2289
- tmp
2290
- );
2291
- git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmp);
2292
- } finally {
2293
- try {
2294
- git(['worktree', 'remove', '--force', tmp], repoRoot);
2295
- git(['worktree', 'prune'], repoRoot);
2296
- } catch {
2297
- /* best effort */
2298
- }
2299
- }
2300
- };
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
+ });
2301
2290
  // Idempotency: base already contains the branch tip. A re-offered
2302
2291
  // job after a lost report lands here — never a re-merge, and never
2303
2292
  // "nothing to ship" AS A FAILURE for work that in fact shipped. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.67.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": {