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 +30 -4
- package/bin/lib/git.mjs +26 -0
- package/bin/lib/repoState.mjs +20 -1
- package/bin/lib/shipMerge.mjs +132 -0
- package/bin/lib/shipSweep.mjs +59 -0
- package/bin/lib/work.mjs +65 -39
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/bin/lib/repoState.mjs
CHANGED
|
@@ -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:
|
|
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,
|
|
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
|
|
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
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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.
|
|
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": {
|